feat(cafe24): 읽기 지연 보정("마지막 쓰기가 권위") + 상품 정보 패널
증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야 반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT 뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만 기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값 백업이 생겼다. - 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이 유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막 쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external). store.resolve_description / db.revision_digests(md5) / 배너 2종. - 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회 확인 결과는 감사로그에만 남긴다. - 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products. last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date 가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷. - 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을 한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/ 소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목 자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다. - client.delete/timeout, products.upload_images·options·variants 래퍼. - 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES, .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -802,6 +802,10 @@ class _FakeStore:
|
||||
self.added.append(fields)
|
||||
return 900 + len(self.added)
|
||||
|
||||
def save_write_snapshot(self, product_no, section, data):
|
||||
self.snapshots = getattr(self, "snapshots", [])
|
||||
self.snapshots.append((product_no, section, data))
|
||||
|
||||
def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
|
||||
self.finished.append(
|
||||
{"id": schedule_id, "status": status, "error": error,
|
||||
@@ -904,6 +908,239 @@ def test_worker_stops_when_nothing_due():
|
||||
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 카페24 읽기 지연 보정 — 마지막 쓰기가 권위
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_resolve_description_no_recent_write_uses_cafe24():
|
||||
html, state = store.resolve_description("cafe", last_write=None, known_digests=set(), grace_minutes=60)
|
||||
assert (html, state) == ("cafe", store.SYNC_NONE)
|
||||
|
||||
|
||||
def _write(html, minutes_ago=1):
|
||||
return {"html_content": html, "created_at": now_kst() - timedelta(minutes=minutes_ago)}
|
||||
|
||||
|
||||
def test_resolve_description_synced_when_cafe24_caught_up():
|
||||
html, state = store.resolve_description(
|
||||
"NEW", last_write=_write("NEW"), known_digests={store.content_digest("OLD")}, grace_minutes=60
|
||||
)
|
||||
assert (html, state) == ("NEW", store.SYNC_SYNCED)
|
||||
|
||||
|
||||
def test_resolve_description_pending_when_cafe24_returns_known_old_value():
|
||||
# 카페24가 아직 적용 직전 값(BACKUP 으로 남긴 값)을 돌려준다 → 마지막 쓰기를 보여준다
|
||||
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
|
||||
html, state = store.resolve_description("OLD", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
|
||||
assert (html, state) == ("NEW", store.SYNC_PENDING)
|
||||
|
||||
|
||||
def test_resolve_description_pending_for_older_known_value_too():
|
||||
# 연속 두 번 적용(A→B→C) 뒤 카페24가 A 를 돌려줘도 '아는 값'이므로 지연으로 본다
|
||||
digests = {store.content_digest(v) for v in ("A", "B", "C")}
|
||||
html, state = store.resolve_description("A", last_write=_write("C"), known_digests=digests, grace_minutes=60)
|
||||
assert (html, state) == ("C", store.SYNC_PENDING)
|
||||
|
||||
|
||||
def test_resolve_description_external_when_unknown_value():
|
||||
# 관리자에서 직접 고친 값 — 카페24를 믿는다
|
||||
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
|
||||
html, state = store.resolve_description("HAND", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
|
||||
assert (html, state) == ("HAND", store.SYNC_EXTERNAL)
|
||||
|
||||
|
||||
def test_resolve_description_grace_expired_trusts_cafe24():
|
||||
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
|
||||
html, state = store.resolve_description(
|
||||
"OLD", last_write=_write("NEW", minutes_ago=500), known_digests=digests, grace_minutes=60
|
||||
)
|
||||
assert (html, state) == ("OLD", store.SYNC_NONE)
|
||||
|
||||
|
||||
def test_resolve_description_accepts_iso_created_at():
|
||||
lw = {"html_content": "NEW", "created_at": (now_kst() - timedelta(minutes=2)).isoformat()}
|
||||
html, state = store.resolve_description("OLD", last_write=lw, known_digests={store.content_digest("OLD")}, grace_minutes=60)
|
||||
assert (html, state) == ("NEW", store.SYNC_PENDING)
|
||||
|
||||
|
||||
def test_parse_grace_minutes():
|
||||
assert store.parse_grace_minutes("30") == 30
|
||||
assert store.parse_grace_minutes("") == store.DEFAULT_READ_LAG_GRACE_MIN
|
||||
assert store.parse_grace_minutes("-5") == store.DEFAULT_READ_LAG_GRACE_MIN
|
||||
assert store.parse_grace_minutes("abc") == store.DEFAULT_READ_LAG_GRACE_MIN
|
||||
|
||||
|
||||
def test_overlay_recent_write_uses_snapshot_when_get_is_older():
|
||||
fetched = {"product_name": "OLD", "price": "1000.00", "updated_date": "2026-09-18T10:00:00+09:00",
|
||||
"description": "keep"}
|
||||
snap = {"product_name": "NEW", "price": "2000.00", "updated_date": "2026-09-18T10:05:00+09:00"}
|
||||
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
|
||||
assert stale is True
|
||||
assert merged["product_name"] == "NEW" and merged["price"] == "2000.00"
|
||||
assert merged["description"] == "keep" # 스냅샷에 없는 필드는 그대로
|
||||
assert merged["updated_date"] == snap["updated_date"]
|
||||
|
||||
|
||||
def test_overlay_recent_write_keeps_cafe24_when_caught_up():
|
||||
fetched = {"product_name": "NEWER", "updated_date": "2026-09-18T10:06:00+09:00"}
|
||||
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
|
||||
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
|
||||
assert stale is False and merged is fetched
|
||||
|
||||
|
||||
def test_overlay_recent_write_skips_without_dates_or_snapshot():
|
||||
fetched = {"product_name": "X"}
|
||||
assert store.overlay_recent_write(fetched, snapshot=None, written_at=now_kst(), grace_minutes=60) == (fetched, False)
|
||||
snap = {"product_name": "NEW"}
|
||||
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) == (fetched, False)
|
||||
|
||||
|
||||
def test_overlay_recent_write_respects_grace():
|
||||
fetched = {"product_name": "OLD", "updated_date": "2026-09-18T10:00:00+09:00"}
|
||||
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
|
||||
old = (now_kst() - timedelta(hours=10)).isoformat()
|
||||
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=old, grace_minutes=60) == (fetched, False)
|
||||
|
||||
|
||||
def test_product_snapshot_keeps_only_scalar_fields():
|
||||
snap = store.product_snapshot({"product_name": "A", "price": "1.00", "description": "<big>", "updated_date": None, "display": "T"})
|
||||
assert snap == {"product_name": "A", "price": "1.00", "display": "T"}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 기본 정보 · 옵션 · 품목 입력 검증
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_parse_price():
|
||||
assert store.parse_price("6,900") == "6900.00"
|
||||
assert store.parse_price("6900.00") == "6900.00"
|
||||
assert store.parse_price(6900) == "6900.00"
|
||||
assert store.parse_price("") is None
|
||||
assert store.parse_price(None) is None
|
||||
for bad in ("abc", "-1"):
|
||||
try:
|
||||
store.parse_price(bad)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(bad)
|
||||
assert store.price_equal("6900.00", "6900") and not store.price_equal("6900.00", "7000")
|
||||
|
||||
|
||||
def test_update_payload_price_and_image():
|
||||
payload = products.build_update_payload(price="1000.00", supply_price="500.00", detail_image="/web/x.jpg")
|
||||
assert payload["request"] == {
|
||||
"price": "1000.00", "supply_price": "500.00", "detail_image": "/web/x.jpg", "image_upload_type": "A",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_option_values():
|
||||
assert store.parse_option_values("빨강, 파랑\n노랑,, 빨강 ") == ["빨강", "파랑", "노랑"]
|
||||
|
||||
|
||||
def test_build_create_options_request():
|
||||
body = store.build_create_options_request("색상", ["빨강", "파랑"], display_type="p")
|
||||
assert body["has_option"] == "T" and body["option_type"] == "T"
|
||||
assert body["options"][0]["option_display_type"] == "P"
|
||||
assert [v["option_text"] for v in body["options"][0]["option_value"]] == ["빨강", "파랑"]
|
||||
for name, values in (("", ["a"]), ("x", [])):
|
||||
try:
|
||||
store.build_create_options_request(name, values)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError((name, values))
|
||||
|
||||
|
||||
def test_build_update_options_request_pairs_original_and_new():
|
||||
original = [{"option_code": "O1", "option_name": "Color", "option_display_type": "S",
|
||||
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
|
||||
edited = [{"option_name": "Colors", "option_display_type": "P",
|
||||
"option_value": [{"option_text": "Jet Black", "option_image_file": "https://d/x.png"},
|
||||
{"option_text": "Deep Red"}]}]
|
||||
body = store.build_update_options_request(original, edited, option_list_type="S")
|
||||
assert body["option_list_type"] == "S"
|
||||
assert body["original_options"] == [{"option_code": "O1", "option_name": "Color",
|
||||
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
|
||||
assert body["options"][0]["option_name"] == "Colors"
|
||||
assert body["options"][0]["option_display_type"] == "P"
|
||||
assert body["options"][0]["option_value"][0] == {"option_text": "Jet Black", "value_no": 1, "option_image_file": "https://d/x.png"}
|
||||
assert body["options"][0]["option_value"][1] == {"option_text": "Deep Red", "value_no": 2}
|
||||
|
||||
|
||||
def test_build_update_options_request_rejects_count_mismatch():
|
||||
original = [{"option_name": "A", "option_value": [{"option_text": "1"}]}]
|
||||
for edited in ([], [{"option_name": "A", "option_value": []}], [{"option_name": "", "option_value": [{"option_text": "1"}]}]):
|
||||
try:
|
||||
store.build_update_options_request(original, edited)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(edited)
|
||||
|
||||
|
||||
def test_build_variant_updates():
|
||||
rows = [
|
||||
{"variant_code": "p000000r000a", "custom_variant_code": " ABC ", "additional_amount": "1,000", "display": "on"},
|
||||
{"variant_code": "P000000R000B"}, # 바뀐 것 없음 → 제외
|
||||
{"variant_code": "P000000R000C", "selling": "off", "additional_amount": ""},
|
||||
]
|
||||
out = store.build_variant_updates(rows)
|
||||
assert out == [
|
||||
{"variant_code": "P000000R000A", "custom_variant_code": "ABC", "additional_amount": "1000.00", "display": "T"},
|
||||
{"variant_code": "P000000R000C", "selling": "F"},
|
||||
]
|
||||
try:
|
||||
store.build_variant_updates([{"variant_code": "bad"}])
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("bad code accepted")
|
||||
|
||||
|
||||
class _RouteClient:
|
||||
"""경로별 응답을 돌려주는 가짜 클라이언트 (get/put/post/delete)."""
|
||||
|
||||
def __init__(self, routes):
|
||||
self.routes = routes
|
||||
self.calls: list[tuple] = []
|
||||
|
||||
def _call(self, method, path, json=None, **_kw):
|
||||
self.calls.append((method, path, json))
|
||||
return self.routes.get(f"{method} {path}", {})
|
||||
|
||||
def get(self, path, **kw):
|
||||
return self._call("GET", path, **kw)
|
||||
|
||||
def put(self, path, **kw):
|
||||
return self._call("PUT", path, **kw)
|
||||
|
||||
def post(self, path, **kw):
|
||||
return self._call("POST", path, **kw)
|
||||
|
||||
def delete(self, path, **kw):
|
||||
return self._call("DELETE", path, **kw)
|
||||
|
||||
|
||||
def test_upload_images_and_variants_wrappers():
|
||||
client = _RouteClient({
|
||||
"POST /admin/products/images": {"images": [{"path": "https://d/a.png"}, {"path": "https://d/b.png"}]},
|
||||
"PUT /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A", "display": "F"}]},
|
||||
"GET /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A"}]},
|
||||
"GET /admin/products/7/options": {"option": {"has_option": "T", "options": []}},
|
||||
"DELETE /admin/products/7/options": {"option": {"product_no": 7}},
|
||||
})
|
||||
assert products.upload_images(client, ["AAA", "BBB"]) == ["https://d/a.png", "https://d/b.png"]
|
||||
assert products.upload_image_bytes(client, b"\x89PNG") == "https://d/a.png"
|
||||
assert products.list_variants(client, 7) == [{"variant_code": "P000000R000A"}]
|
||||
assert products.get_options(client, 7)["has_option"] == "T"
|
||||
assert products.update_variants(client, 7, [{"variant_code": "P000000R000A", "display": "F"}]) == [
|
||||
{"variant_code": "P000000R000A", "display": "F"}
|
||||
]
|
||||
sent = [c for c in client.calls if c[0] == "PUT"][-1]
|
||||
assert sent[2]["requests"][0]["variant_code"] == "P000000R000A"
|
||||
products.delete_options(client, 7)
|
||||
assert client.calls[-1][0] == "DELETE"
|
||||
|
||||
|
||||
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