feat(cafe24): 예약관리 — 지정 시각에 상세페이지·진열/판매 자동 적용 (Phase 5)
되돌리기(자동 복원)는 요청대로 만들지 않았다. 예약은 "그 시각에 이 내용을 적용" 하나뿐이며, 한 예약에서 상세페이지 HTML·진열·판매를 각각 고를 수 있다. 셋 다 "변경 없음"인 예약은 DB CHECK 로 막는다. 등록은 편집기 아래 「예약 적용」에서 한다. HTML 을 적용하는 예약이면 그 시점의 편집기 내용을 DRAFT revision 으로 저장해 고정한다 — 이후 편집기를 더 고쳐도 예약된 내용이 바뀌지 않아야 한다. 단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로 화면에서 본 값이 그대로 저장된다. 예약 폼은 적용 폼과 형제로 두고(폼 중첩 불가) 편집기 내용을 JS 가 hidden 에 복사한다. 실행은 web 이 아니라 worker 다(app/modules/cafe24/worker.py, compose 서비스 dbx-cafe24-worker, --loop 60). 웹 요청 안에서 기다리면 프록시 타임아웃·재기동에 무너지고, 브라우저를 닫으면 실행되지 않는다. worker 는 claim_due_schedule 로 한 건씩 FOR UPDATE SKIP LOCKED 로 잠그고 PROCESSING 으로 바꾼 뒤 잠금을 푼다. worker 가 둘 떠도 같은 예약을 두 번 적용하지 않고, 긴 API 호출 동안 DB 잠금을 쥐지 않는다. 적용 순서는 화면 편집과 같다(현재값 재조회 → BACKUP → PUT → 감사로그). HTML 없이 진열/판매만 바꾸는 예약은 상세설명을 읽지도 백업하지도 않는다. 실패는 1분→5분→15분 재시도 후 FAILED 확정이며, 한 건의 오류로 worker 가 죽지 않는다. 진열/판매를 한 번의 PUT 으로 함께 보내려고 products.update_product 를 추가했다 (update_descriptions 는 이 함수로 위임). None 인 필드는 payload 에서 빼므로 "건드리지 않음"이 그대로 표현된다. DB: scripts/sql/cafe24_db_002_schedule_flags.sql (멱등) — set_display/set_selling BOOLEAN NULL 추가 + 아무것도 하지 않는 예약 금지 제약. 되돌리기용 end_* 컬럼은 쓰지 않지만 삭제하지 않는다(파괴적). 시각은 KST 로 해석한다(datetime-local 은 타임존이 없다). 과거는 거부하되 폼을 채우는 동안 시간이 흐른 경우를 위해 1분 여유를 뒀다. 검증: 유닛테스트 66개 통과(신규 15개 — 3-상태 파싱, KST 해석·과거 거부·1분 여유, 요약 문구, payload 의 T/F 와 None 생략, 바꿀 것 없으면 미호출, worker 의 성공 경로 (백업+PC/모바일 동시+진열만 전송)·상태만 변경 시 백업 생략·재시도 후 최종 실패· 버전 누락 시 크래시 대신 실패·처리할 것 없을 때 종료). 예약 목록/편집기 예약 폼 렌더 확인. 라우트 16개. 실제 예약 실행은 서버 배포 후 확인 필요. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,8 @@ from datetime import timedelta
|
||||
|
||||
from app.integrations.cafe24 import config as cfgmod
|
||||
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.integrations.cafe24.errors import Cafe24ApiError, Cafe24AuthError, Cafe24ConfigError
|
||||
from app.modules.cafe24 import store, worker
|
||||
from app.timezone import now_kst
|
||||
|
||||
SECRET = "unit-test-secret"
|
||||
@@ -598,6 +598,211 @@ def test_fingerprint_detects_change():
|
||||
assert len(a) == 32
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 예약 — 입력 검증
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_parse_tristate():
|
||||
assert store.parse_tristate("on") is True
|
||||
assert store.parse_tristate("off") is False
|
||||
for keep in ("", None, "keep", "이상한값"):
|
||||
assert store.parse_tristate(keep) is None, keep
|
||||
|
||||
|
||||
def test_parse_schedule_at_accepts_future_kst():
|
||||
base = now_kst()
|
||||
target = (base + timedelta(hours=3)).replace(second=0, microsecond=0)
|
||||
parsed = store.parse_schedule_at(target.strftime("%Y-%m-%dT%H:%M"), now=base)
|
||||
assert parsed.utcoffset() == timedelta(hours=9) # 타임존 표기 없는 입력을 KST 로 해석
|
||||
assert parsed.hour == target.hour and parsed.minute == target.minute
|
||||
|
||||
|
||||
def test_parse_schedule_at_rejects_past_and_garbage():
|
||||
base = now_kst()
|
||||
past = (base - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
|
||||
for bad in (past, "", "어제", "2026-13-45T99:99"):
|
||||
try:
|
||||
store.parse_schedule_at(bad, now=base)
|
||||
except ValueError:
|
||||
continue
|
||||
raise AssertionError(f"{bad!r} 는 거부해야 한다")
|
||||
|
||||
|
||||
def test_parse_schedule_at_allows_one_minute_grace():
|
||||
"""폼을 채우는 동안 시간이 흐른 경우를 거부하지 않는다."""
|
||||
base = now_kst()
|
||||
just_now = (base - timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M")
|
||||
assert store.parse_schedule_at(just_now, now=base) is not None
|
||||
|
||||
|
||||
def test_describe_schedule_action():
|
||||
assert store.describe_schedule_action(
|
||||
has_html=True, set_display=True, set_selling=False
|
||||
) == "상세페이지 · 진열 · 판매중지"
|
||||
assert store.describe_schedule_action(
|
||||
has_html=False, set_display=None, set_selling=True
|
||||
) == "판매"
|
||||
assert store.describe_schedule_action(
|
||||
has_html=False, set_display=None, set_selling=None
|
||||
) == "없음"
|
||||
|
||||
|
||||
def test_normalize_schedule_status():
|
||||
assert store.normalize_schedule_status("success") == store.STATUS_SUCCESS
|
||||
assert store.normalize_schedule_status("없는상태") == store.STATUS_PENDING
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 예약 — 상품 수정 payload (진열/판매 포함)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def test_update_payload_flags():
|
||||
payload = products.build_update_payload(display=True, selling=False)
|
||||
assert payload == {"request": {"display": "T", "selling": "F"}}
|
||||
|
||||
|
||||
def test_update_payload_skips_none():
|
||||
"""None 인 항목은 아예 보내지 않는다 = 그 필드를 건드리지 않는다."""
|
||||
payload = products.build_update_payload(description="<p>x</p>")
|
||||
assert payload["request"] == {"description": "<p>x</p>"}
|
||||
|
||||
|
||||
def test_update_product_skips_empty_request():
|
||||
"""바꿀 것이 없으면 API 를 호출하지 않는다."""
|
||||
client = _FakeClient({"product": _PRODUCT})
|
||||
assert products.update_product(client, 131) == {}
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_update_product_sends_flags_and_html():
|
||||
client = _FakeClient({"product": _PRODUCT})
|
||||
products.update_product(client, 131, description="<p>새</p>", display=False)
|
||||
call = client.calls[0]
|
||||
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
|
||||
assert call["json"] == {"request": {"description": "<p>새</p>", "display": "F"}}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 예약 worker — 성공/재시도/최종실패
|
||||
# ════════════════════════════════════════════════════════════
|
||||
class _FakeStore:
|
||||
"""worker 가 쓰는 저장소 메서드만 흉내낸다."""
|
||||
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
self.revisions = {}
|
||||
self.added = []
|
||||
self.finished = []
|
||||
self.audits = []
|
||||
|
||||
@contextmanager
|
||||
def claim_due_schedule(self, *, now):
|
||||
yield self.rows.pop(0) if self.rows else None
|
||||
|
||||
def get_revision(self, revision_id):
|
||||
return self.revisions.get(int(revision_id), {})
|
||||
|
||||
def add_revision(self, **fields):
|
||||
self.added.append(fields)
|
||||
return 900 + len(self.added)
|
||||
|
||||
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,
|
||||
"next_retry_at": next_retry_at, "retry_count": retry_count}
|
||||
)
|
||||
|
||||
def log_audit(self, **fields):
|
||||
self.audits.append(fields)
|
||||
|
||||
|
||||
class _FakeApi:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
|
||||
class _WorkerClient(_FakeClient):
|
||||
"""PUT 을 실패시킬 수 있는 클라이언트."""
|
||||
|
||||
def __init__(self, payload=None, fail_put=None):
|
||||
super().__init__(payload)
|
||||
self.fail_put = fail_put
|
||||
|
||||
def put(self, path, *, params=None, json=None, product_no=None):
|
||||
if self.fail_put:
|
||||
raise self.fail_put
|
||||
return super().put(path, params=params, json=json, product_no=product_no)
|
||||
|
||||
|
||||
def _schedule_row(**overrides):
|
||||
row = {
|
||||
"id": 7, "product_no": 131, "revision_id": 55,
|
||||
"set_display": True, "set_selling": None, "retry_count": 0,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
def test_worker_applies_html_and_flags():
|
||||
st = _FakeStore([_schedule_row()])
|
||||
st.revisions[55] = {"html_content": "<p>예약 내용</p>"}
|
||||
client = _WorkerClient({"product": _PRODUCT})
|
||||
assert worker.process_once(st, _FakeApi(client)) == 1
|
||||
|
||||
# 쓰기 직전 현재값을 읽어 BACKUP 을 남겼는가
|
||||
assert any(r["revision_type"] == store.REVISION_BACKUP for r in st.added)
|
||||
# HTML 과 진열 상태를 한 번의 PUT 으로 보냈는가
|
||||
put = [c for c in client.calls if c["method"] == "PUT"][0]
|
||||
assert put["json"]["request"]["description"] == "<p>예약 내용</p>"
|
||||
assert put["json"]["request"]["mobile_description"] == "<p>예약 내용</p>"
|
||||
assert put["json"]["request"]["display"] == "T"
|
||||
assert "selling" not in put["json"]["request"] # 변경 없음이면 보내지 않는다
|
||||
assert st.finished == [
|
||||
{"id": 7, "status": store.STATUS_SUCCESS, "error": "",
|
||||
"next_retry_at": None, "retry_count": None}
|
||||
]
|
||||
|
||||
|
||||
def test_worker_flags_only_skips_backup():
|
||||
"""HTML 없이 진열/판매만 바꾸는 예약은 상세설명을 읽거나 백업하지 않는다."""
|
||||
st = _FakeStore([_schedule_row(revision_id=None, set_selling=False)])
|
||||
client = _WorkerClient({"product": _PRODUCT})
|
||||
assert worker.process_once(st, _FakeApi(client)) == 1
|
||||
assert st.added == [] # 백업 없음
|
||||
put = [c for c in client.calls if c["method"] == "PUT"][0]
|
||||
assert "description" not in put["json"]["request"]
|
||||
assert put["json"]["request"] == {"display": "T", "selling": "F"}
|
||||
|
||||
|
||||
def test_worker_retries_then_fails():
|
||||
"""실패는 재시도 예산 안에서 다시 시도하고, 소진되면 FAILED 로 확정한다."""
|
||||
boom = Cafe24ApiError("서버 오류", status=500)
|
||||
|
||||
st = _FakeStore([_schedule_row(retry_count=0)])
|
||||
st.revisions[55] = {"html_content": "<p>x</p>"}
|
||||
worker.process_once(st, _FakeApi(_WorkerClient({"product": _PRODUCT}, fail_put=boom)))
|
||||
first = st.finished[0]
|
||||
assert first["status"] == store.STATUS_PENDING # 다시 대기로
|
||||
assert first["retry_count"] == 1
|
||||
assert first["next_retry_at"] is not None
|
||||
|
||||
st2 = _FakeStore([_schedule_row(retry_count=store.MAX_RETRY)])
|
||||
st2.revisions[55] = {"html_content": "<p>x</p>"}
|
||||
worker.process_once(st2, _FakeApi(_WorkerClient({"product": _PRODUCT}, fail_put=boom)))
|
||||
assert st2.finished[0]["status"] == store.STATUS_FAILED
|
||||
assert any(a["result"] == "FAIL" for a in st2.audits)
|
||||
|
||||
|
||||
def test_worker_missing_revision_is_failure_not_crash():
|
||||
st = _FakeStore([_schedule_row(revision_id=999, retry_count=store.MAX_RETRY)])
|
||||
worker.process_once(st, _FakeApi(_WorkerClient({"product": _PRODUCT})))
|
||||
assert st.finished[0]["status"] == store.STATUS_FAILED
|
||||
assert "999" in st.finished[0]["error"]
|
||||
|
||||
|
||||
def test_worker_stops_when_nothing_due():
|
||||
st = _FakeStore([])
|
||||
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
|
||||
|
||||
|
||||
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