feat(cafe24): 상품 목록·검색 + 현재 상세페이지 HTML 조회 (Phase 2)
상세설명 API 경로가 틀려 있던 것을 실물 확인으로 바로잡았다.
`/admin/products/{no}/description` 은 존재하지 않는다(운영몰 호출 결과
`No API found.`). 상세설명은 상품 리소스의 필드이므로 GET/PUT 을
`/admin/products/{no}` 로 옮겼고, PUT body 는 {"request": {...}} 다.
PC/모바일 상세설명이 별도 필드라는 것도 확인됐다. `separated_mobile_description`
('T'/'F') 이 분리 사용 여부이며, 미분리 상품을 수정할 때 description 만 바꾸면
모바일이 어긋난다. Descriptions 데이터클래스에 이 플래그와 불일치 여부를 담아
화면에서 경고로 노출한다.
목록 응답에는 description 이 없어(확인됨) 상세설명은 상품 1건씩 조회한다.
그래서 목록 화면에 미리보기를 뿌리지 않는다 — 상품 87개면 87호출이라 호출
제한에 걸린다.
화면은 읽기 전용이다(편집·적용은 Phase 3~4). 목록은 카페24를 매번 조회해
현재값을 보여주고, 결과를 cafe24_products 에 UPSERT 해둔다(예약·로그 화면에서
API 없이 상품명을 쓰기 위함).
상단 탭의 예약관리가 404 였으므로 Phase 5 안내 화면을 붙였다.
토큰 만료 시각이 화면에 +00:00 로 보이던 것도 고쳤다. 컬럼이 timestamptz 라
psycopg 가 UTC 로 돌려주는 값을 그대로 출력하고 있었다(시각 자체는 정확했다).
검증: 유닛테스트 23개 통과(신규 7개 — 상세설명 경로가 /description 으로
되돌아가지 않는지, PUT payload 모양, 미분리 플래그 파싱, 페이징 clamp).
라우트 8개 등록 확인. 실제 화면은 서버 배포 후 확인 필요.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
from app.integrations.cafe24 import config as cfgmod
|
||||
from app.integrations.cafe24 import crypto, oauth, tokens
|
||||
from app.integrations.cafe24 import crypto, oauth, products, tokens
|
||||
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
|
||||
from app.modules.cafe24 import store
|
||||
from app.timezone import now_kst
|
||||
@@ -240,6 +240,108 @@ def test_parse_product_no():
|
||||
raise AssertionError(f"{bad!r} 는 거부해야 한다")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 상품 엔드포인트 래퍼
|
||||
# 실제 쇼핑몰 확인 결과 /admin/products/{no}/description 은 존재하지 않는다
|
||||
# (`No API found.`). 상세설명은 상품 리소스의 필드다 — 경로가 되돌아가지 않게
|
||||
# 여기서 고정한다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
class _FakeClient:
|
||||
"""Cafe24Client 의 get/put 만 흉내내고 호출을 기록한다."""
|
||||
|
||||
def __init__(self, payload=None):
|
||||
self.payload = payload or {}
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def get(self, path, *, params=None, json=None, product_no=None):
|
||||
self.calls.append({"method": "GET", "path": path, "params": params})
|
||||
return self.payload
|
||||
|
||||
def put(self, path, *, params=None, json=None, product_no=None):
|
||||
self.calls.append({"method": "PUT", "path": path, "json": json})
|
||||
return self.payload
|
||||
|
||||
|
||||
_PRODUCT = {
|
||||
"product_no": 131,
|
||||
"product_code": "P000000B",
|
||||
"product_name": "빠져락 1개(사은품)",
|
||||
"display": "T",
|
||||
"selling": "F",
|
||||
"description": "<p>PC</p>",
|
||||
"mobile_description": "<p>PC</p>",
|
||||
"separated_mobile_description": "F",
|
||||
}
|
||||
|
||||
|
||||
def test_descriptions_from_product():
|
||||
desc = products.descriptions_from_product(_PRODUCT)
|
||||
assert desc.product_no == 131
|
||||
assert desc.description == "<p>PC</p>"
|
||||
assert desc.separated_mobile is False
|
||||
assert desc.mobile_differs is False
|
||||
|
||||
|
||||
def test_descriptions_separated_mobile_and_diff():
|
||||
desc = products.descriptions_from_product(
|
||||
{**_PRODUCT, "separated_mobile_description": "T", "mobile_description": "<p>MO</p>"}
|
||||
)
|
||||
assert desc.separated_mobile is True
|
||||
assert desc.mobile_differs is True
|
||||
|
||||
|
||||
def test_fetch_descriptions_uses_product_resource():
|
||||
client = _FakeClient({"product": _PRODUCT})
|
||||
desc = products.fetch_descriptions(client, 131)
|
||||
assert desc.description == "<p>PC</p>"
|
||||
paths = [c["path"] for c in client.calls]
|
||||
assert paths == ["/admin/products/131"], paths
|
||||
assert not any(p.endswith("/description") for p in paths)
|
||||
|
||||
|
||||
def test_update_descriptions_payload():
|
||||
client = _FakeClient({"product": _PRODUCT})
|
||||
products.update_descriptions(client, 131, description="<p>NEW</p>")
|
||||
call = client.calls[0]
|
||||
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
|
||||
# 준 필드만 바뀌어야 한다 — 모바일을 지정하지 않으면 보내지 않는다.
|
||||
assert call["json"] == {"request": {"description": "<p>NEW</p>"}}
|
||||
|
||||
|
||||
def test_update_payload_optional_fields():
|
||||
both = products.build_update_payload(
|
||||
description="<p>PC</p>", mobile_description="<p>MO</p>", shop_no=1
|
||||
)
|
||||
assert both == {"shop_no": 1, "request": {"description": "<p>PC</p>", "mobile_description": "<p>MO</p>"}}
|
||||
# 빈 문자열은 "모바일을 비운다"는 뜻이므로 None 과 구분해 전달돼야 한다.
|
||||
assert products.build_update_payload(description="x", mobile_description="")["request"] == {
|
||||
"description": "x",
|
||||
"mobile_description": "",
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_product_flags():
|
||||
row = products.normalize_product(_PRODUCT)
|
||||
assert row == {
|
||||
"product_no": 131,
|
||||
"product_code": "P000000B",
|
||||
"product_name": "빠져락 1개(사은품)",
|
||||
"display": True,
|
||||
"selling": False,
|
||||
}
|
||||
# 값이 없으면 기본 True(카페24 응답에 필드가 빠진 경우 진열 중으로 본다).
|
||||
assert products.normalize_product({"product_no": "9"})["display"] is True
|
||||
|
||||
|
||||
def test_list_products_clamps_paging():
|
||||
client = _FakeClient({"products": []})
|
||||
products.list_products(client, limit=999, offset=-5, product_name="락")
|
||||
params = client.calls[0]["params"]
|
||||
assert params["limit"] == products.PAGE_LIMIT
|
||||
assert params["offset"] == 0
|
||||
assert params["product_name"] == "락"
|
||||
|
||||
|
||||
def _run_all():
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
|
||||
Reference in New Issue
Block a user