"""카페24 모듈 순수 로직 + 토큰/암호화 테스트. DB/네트워크 없이 검증한다(가짜 저장소 + refresh 함수 주입). python -m app.modules.cafe24.tests.test_cafe24 또는 pytest 로 실행 가능. """ from __future__ import annotations import os from contextlib import contextmanager 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 Cafe24ApiError, Cafe24AuthError, Cafe24ConfigError from app.modules.cafe24 import store, worker from app.timezone import now_kst SECRET = "unit-test-secret" # wait_for_description 은 실물 카페24 API 의 쓰기 직후 읽기 지연을 흡수하려고 # 실제로 sleep 한다. 가짜 클라이언트는 그 지연을 재현하지 않으므로(항상 같은 # 값을 돌려줌) 재시도 예산을 다 채우게 되는데, 테스트에서까지 그 시간을 그대로 # 기다릴 필요는 없다. products.time.sleep = lambda *_a, **_k: None _TEST_ENV = { "CAFE24_MALL_ID": "testmall", "CAFE24_CLIENT_ID": "cid", "CAFE24_CLIENT_SECRET": "csecret", "CAFE24_REDIRECT_URI": "http://localhost:8080/cafe24/oauth/callback", "CAFE24_TOKEN_SECRET": SECRET, } def _config(): """환경변수에 의존하지 않도록 테스트용 값을 주입해 설정을 만든다.""" saved = {k: os.environ.get(k) for k in _TEST_ENV} os.environ.update(_TEST_ENV) try: return cfgmod.load_config() finally: for key, value in saved.items(): if value is None: os.environ.pop(key, None) else: os.environ[key] = value class _FakeRepo: """Cafe24Store 의 토큰 3개 메서드만 흉내낸다.""" def __init__(self, row=None): self.row = row self.saves: list[dict] = [] def get_token_row(self, mall_id): return self.row def save_token_row(self, *, mall_id, **fields): self.saves.append(fields) if self.row is None: self.row = {"mall_id": mall_id} self.row.update(fields) @contextmanager def token_lock(self, mall_id): outer = self class Handle: row = outer.row def save(self, **fields): outer.save_token_row(mall_id=mall_id, **fields) yield Handle() def _row(**overrides): row = { "mall_id": "testmall", "access_token": crypto.encrypt("AT", secret=SECRET), "refresh_token": crypto.encrypt("RT", secret=SECRET), "access_token_expires_at": now_kst() + timedelta(hours=1), "refresh_token_expires_at": now_kst() + timedelta(days=13), "scopes": "mall.read_product,mall.write_product", "last_refreshed_at": now_kst(), "last_error": "", "connected_by": "king@dbxcorp.co.kr", } row.update(overrides) return row # ════════════════════════════════════════════════════════════ # 암호화 # ════════════════════════════════════════════════════════════ def test_crypto_roundtrip(): token = "ACCESS-TOKEN-한글-123" encrypted = crypto.encrypt(token, secret=SECRET) assert encrypted != token and token not in encrypted assert crypto.decrypt(encrypted, secret=SECRET) == token def test_crypto_empty_passthrough(): assert crypto.encrypt("", secret=SECRET) == "" assert crypto.decrypt("", secret=SECRET) == "" def test_crypto_wrong_secret_raises(): encrypted = crypto.encrypt("AT", secret=SECRET) try: crypto.decrypt(encrypted, secret="다른키") except Cafe24ConfigError: return raise AssertionError("키가 바뀌면 Cafe24ConfigError 가 나야 한다") def test_crypto_requires_secret(): try: crypto.encrypt("x", secret="") except Cafe24ConfigError: return raise AssertionError("CAFE24_TOKEN_SECRET 없으면 예외여야 한다") # ════════════════════════════════════════════════════════════ # 설정 / 인증 URL # ════════════════════════════════════════════════════════════ def test_config_basics(): config = _config() assert config.configured assert config.missing == [] assert config.api_base == "https://testmall.cafe24api.com/api/v2" # 요청 scope 는 DEFAULT_SCOPES 한 곳에서 나온다(authorize 와 클라이언트가 # 어긋나지 않게). 늘어날 때 이 테스트도 함께 갱신할 것. assert config.scope_param == ",".join(cfgmod.DEFAULT_SCOPES) assert "mall.read_product" in config.scope_param assert "mall.write_product" in config.scope_param def test_product_url_uses_shop_url_env(): """다이렉트 주소는 CAFE24_SHOP_URL 기준. 스킴·끝 슬래시가 없어도 맞춘다.""" saved = os.environ.get("CAFE24_SHOP_URL") try: for value in ("https://miras.co.kr", "miras.co.kr", "https://miras.co.kr/"): os.environ["CAFE24_SHOP_URL"] = value config = _config() assert ( config.product_url(119) == "https://miras.co.kr/product/detail.html?product_no=119" ), value finally: if saved is None: os.environ.pop("CAFE24_SHOP_URL", None) else: os.environ["CAFE24_SHOP_URL"] = saved def test_product_url_falls_back_to_cafe24_domain(): """CAFE24_SHOP_URL 이 없어도 항상 유효한 주소가 나와야 한다.""" saved = os.environ.pop("CAFE24_SHOP_URL", None) try: assert _config().product_url(119) == ( "https://testmall.cafe24.com/product/detail.html?product_no=119" ) finally: if saved is not None: os.environ["CAFE24_SHOP_URL"] = saved def test_authorize_url_has_state_and_no_secret(): url = oauth.build_authorize_url(_config(), state="STATE123") assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?") assert "state=STATE123" in url # client_secret 은 authorize 단계에 절대 실리면 안 된다. assert "csecret" not in url # ════════════════════════════════════════════════════════════ # 토큰 상태 / 자동 갱신 # ════════════════════════════════════════════════════════════ def test_status_without_token(): status = tokens.TokenService(_FakeRepo(None), _config()).status() assert status["connected"] is False assert status["needs_reauth"] is True def test_status_never_leaks_token_values(): service = tokens.TokenService(_FakeRepo(_row()), _config()) status = service.status() assert status["connected"] is True assert "AT" not in str(status) and "RT" not in str(status) def test_valid_token_returned_without_refresh(): service = tokens.TokenService(_FakeRepo(_row()), _config()) assert service.get_access_token() == "AT" def test_expired_refresh_token_needs_reauth(): row = _row(refresh_token_expires_at=now_kst() - timedelta(days=1)) assert tokens.TokenService(_FakeRepo(row), _config()).status()["needs_reauth"] is True def test_expired_access_token_triggers_refresh(): """만료된 access token 은 refresh 후 새 값을 돌려주고, 저장은 암호문으로 한다.""" repo = _FakeRepo(_row(access_token_expires_at=now_kst() - timedelta(minutes=5))) service = tokens.TokenService(repo, _config()) seen: list[str] = [] def fake_refresh(config, *, refresh_token): seen.append(refresh_token) return oauth.TokenBundle( access_token="NEW-AT", refresh_token="NEW-RT", access_token_expires_at=now_kst() + timedelta(hours=2), refresh_token_expires_at=now_kst() + timedelta(days=14), scopes="mall.read_product,mall.write_product", ) original = tokens.refresh_tokens tokens.refresh_tokens = fake_refresh try: assert service.get_access_token() == "NEW-AT" finally: tokens.refresh_tokens = original assert seen == ["RT"] # 복호화된 refresh token 이 전달돼야 한다 saved = repo.saves[-1] assert saved["access_token"] != "NEW-AT" # 평문 저장 금지 assert crypto.decrypt(saved["access_token"], secret=SECRET) == "NEW-AT" assert saved["last_error"] == "" def test_dead_refresh_token_raises_auth_error(): row = _row( access_token_expires_at=now_kst() - timedelta(minutes=1), refresh_token_expires_at=now_kst() - timedelta(days=1), ) service = tokens.TokenService(_FakeRepo(row), _config()) try: service.get_access_token() except Cafe24AuthError as exc: assert exc.needs_reauth is True return raise AssertionError("refresh token 만료 시 Cafe24AuthError 여야 한다") # ════════════════════════════════════════════════════════════ # store.py 순수 로직 # ════════════════════════════════════════════════════════════ def test_schedule_editable_only_when_pending(): assert store.is_editable("PENDING") is True for locked in ("PROCESSING", "SUCCESS", "FAILED", "CANCELLED"): assert store.is_editable(locked) is False, locked def test_retry_budget_and_backoff(): assert all(store.can_retry(i) for i in range(store.MAX_RETRY)) assert store.can_retry(store.MAX_RETRY) is False # 무한 재시도 방지 — 간격은 증가하되 상한이 있다. assert store.retry_backoff_seconds(0) < store.retry_backoff_seconds(1) assert store.retry_backoff_seconds(99) == store.retry_backoff_seconds(2) def test_normalize_revision_type(): assert store.normalize_revision_type("backup") == store.REVISION_BACKUP assert store.normalize_revision_type("nope") == store.REVISION_DRAFT def test_parse_product_no(): assert store.parse_product_no(" 123 ") == 123 for bad in ("abc", "0", "-3", None, ""): try: store.parse_product_no(bad) except ValueError: continue 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": "

PC

", "mobile_description": "

PC

", "separated_mobile_description": "F", } def test_descriptions_from_product(): desc = products.descriptions_from_product(_PRODUCT) assert desc.product_no == 131 assert desc.description == "

PC

" 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": "

MO

"} ) 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 == "

PC

" 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="

NEW

") call = client.calls[0] assert call["method"] == "PUT" and call["path"] == "/admin/products/131" # mobile_description 은 절대 보내지 않는다 — 보내면 카페24가 모바일 상세설명 # 설정을 "직접 등록"으로 바꿔버린다(실물 확인). separated_mobile_description="F" # 만 지정해 "PC 상세설명과 동일"을 강제한다. assert call["json"] == {"request": {"description": "

NEW

", "separated_mobile_description": "F"}} def test_update_payload_optional_fields(): both = products.build_update_payload( description="

PC

", mobile_description="

MO

", shop_no=1 ) assert both == {"shop_no": 1, "request": {"description": "

PC

", "mobile_description": "

MO

"}} # 빈 문자열은 "모바일을 비운다"는 뜻이므로 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 class _PagingClient: """페이지를 넘겨가며 응답하는 가짜 클라이언트.""" def __init__(self, count: int): self.count = count self.calls: list[dict] = [] def get(self, path, *, params=None, json=None, product_no=None): self.calls.append(dict(params or {})) offset = int((params or {}).get("offset", 0)) limit = int((params or {}).get("limit", 100)) page = [{"product_no": n} for n in range(offset, min(offset + limit, self.count))] return {"products": page} def test_list_all_products_walks_pages(): client = _PagingClient(230) rows, truncated = products.list_all_products(client) assert len(rows) == 230 and truncated is False # 100 + 100 + 30 → 3회 호출로 끝나야 한다. assert len(client.calls) == 3 assert [c["offset"] for c in client.calls] == [0, 100, 200] def test_list_all_products_stops_at_cap(): """상한을 넘으면 잘렸다고 알린다 — 무한 호출로 API 제한에 걸리지 않게.""" client = _PagingClient(10_000) rows, truncated = products.list_all_products(client, max_items=150) assert len(rows) == 150 and truncated is True def test_list_all_products_single_page(): """현재 쇼핑몰(87개)은 1회 호출로 끝난다.""" client = _PagingClient(87) rows, truncated = products.list_all_products(client) assert len(rows) == 87 and truncated is False assert len(client.calls) == 1 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"] == "락" # ════════════════════════════════════════════════════════════ # 이미지 URL 한글 파일명 표시 ↔ 저장 (왕복 보존이 핵심) # ════════════════════════════════════════════════════════════ _REAL_IMG = "%EC%9A%A9%EA%B8%B0%EB%83%84%EC%83%88%EC%B0%A8%EB%8B%A8(%ED%99%A9%ED%86%A0)_12.gif" _REAL_HTML = f'용기 냄새차단' def test_decode_shows_korean_filename(): decoded = store.decode_html_urls(_REAL_HTML) assert "용기냄새차단(황토)_12.gif" in decoded assert "%EC%9A%A9" not in decoded # 괄호는 원본에서 인코딩돼 있지 않으므로 그대로 남아야 한다. assert "(황토)" in decoded def test_url_roundtrip_is_byte_identical(): """편집하지 않고 적용해도 카페24 저장값이 달라지면 안 된다.""" assert store.encode_html_urls(store.decode_html_urls(_REAL_HTML)) == _REAL_HTML def test_ascii_escapes_are_not_decoded(): """%20·%3C 를 풀면 URL·HTML 구조가 깨진다 — 건드리지 않는다.""" html = '' assert store.decode_html_urls(html) == html assert store.encode_html_urls(html) == html def test_encode_leaves_body_text_alone(): """본문 한글은 인코딩 대상이 아니다(URL 속성값만 바꾼다).""" html = '

여름 특가 안내

보기' encoded = store.encode_html_urls(html) assert "

여름 특가 안내

" in encoded assert 'href="/web/%EC%97%AC%EB%A6%84.html"' in encoded def test_css_url_is_handled(): html = "" assert "황토.png" in store.decode_html_urls(html) assert store.encode_html_urls(store.decode_html_urls(html)) == html def test_invalid_utf8_sequence_left_alone(): """EUC-KR 등 UTF-8 이 아닌 이스케이프는 깨뜨리지 않고 그대로 둔다.""" html = '' assert store.decode_html_urls(html) == html # ════════════════════════════════════════════════════════════ # 소스 정리(포맷) — 렌더링을 바꾸지 않는 것이 최우선 # ════════════════════════════════════════════════════════════ _MESSY = ( '' '

안녕하세요 여름 특가

' '' "
12
" ) def test_format_breaks_block_tags(): out = store.format_html(_MESSY) lines = out.split("\n") assert '
' in lines assert "
" in lines # 블록 안쪽은 들여쓴다. assert any(line.startswith(" ") for line in lines) assert any(line.startswith(" ") == "" # 길면 나눈다. long_text = "가" * 200 assert "\n" in store.format_html("" % long_text) def test_format_survives_broken_html(): """닫는 태그 누락·꺾쇠 조각이 있어도 예외 없이 뭔가를 돌려준다.""" for bad in ("

열고 안 닫음", "a < b 그리고 c > d", "<<>>", "x

") assert payload["request"] == {"description": "

x

"} 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="

", display=False) call = client.calls[0] assert call["method"] == "PUT" and call["path"] == "/admin/products/131" assert call["json"] == {"request": {"description": "

", "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": "

예약 내용

"} 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"] == "

예약 내용

" # mobile_description 은 보내지 않는다 — separated_mobile_description="F" 로 # "PC 상세설명과 동일"을 강제한다(직접 보내면 "직접 등록"으로 바뀌어버린다). assert "mobile_description" not in put["json"]["request"] assert put["json"]["request"]["separated_mobile_description"] == "F" 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": "

x

"} 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": "

x

"} 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: fn() print("PASS", fn.__name__) print(f"\n{len(fns)} tests passed.") if __name__ == "__main__": _run_all()
") for line in lines) def test_format_keeps_inline_elements_together(): """이미지 사이에 줄바꿈이 들어가면 화면에 공백이 생긴다 — 붙여둬야 한다.""" out = store.format_html(_MESSY) assert '' in out assert "

안녕하세요 여름 특가

" in out def test_format_preserves_style_content_verbatim(): out = store.format_html(_MESSY) assert "\t/* 주석 */" in out assert "\t.v{max-width:100%}" in out def test_format_is_idempotent(): """편집하지 않고 다시 적용해도 저장값이 계속 바뀌면 안 된다.""" once = store.format_html(_MESSY) assert store.format_html(once) == once assert store.format_html(store.format_html(once)) == once _REAL_DETAIL = """
""" def test_format_indents_every_line_of_a_run(): """원문 줄바꿈을 살리고 **모든 줄**을 들여쓴다. 예전에는 첫 줄만 들여쓰고 나머지가 1열에 붙어 나왔다. """ lines = store.format_html(_REAL_DETAIL).split("\n") img_lines = [line for line in lines if "` 는 붙여둔다 — 나누면 라벨과 대상이 떨어진다.""" out = store.format_html(_REAL_DETAIL) assert "\n\n\n\n\n\n\n") assert "\n\n" in out assert "\n\n\n" not in out def test_format_real_detail_is_idempotent(): once = store.format_html(_REAL_DETAIL) assert store.format_html(once) == once def test_format_collapses_short_blocks(): assert store.format_html("
11%s