"""네이버 모바일 쇼핑 파서 테스트 (저장 HTML — 네트워크 불필요). fixtures/naver_msearch.html 은 2026-08-04 실측 응답에서 카드만 추린 축약본이다 (판매처 6 · 가격비교 6 · 광고 3 · 슈퍼적립 1 · 브랜드블록 2). 가격 함정 회귀 테스트를 합성 HTML 로 따로 둔다 — 픽스처는 네이버가 마크업을 바꾸면 같이 늙지만, 함정 자체(단위가격·배송비·정상가)는 계속 지켜야 하는 계약이라서다. """ from pathlib import Path from services.search.naver_shop.adapter import detect_block from services.search.naver_shop.parser import parse_search_html _FIXTURE = Path(__file__).parent / "fixtures" / "naver_msearch.html" def _fixture() -> str: return _FIXTURE.read_text() def _card(inner: str, cls: str = "product_item_inner__9nwv0") -> str: return f'
{inner}
' # ── 픽스처 기반 ────────────────────────────────────────────────────────── def test_parses_organic_cards_and_drops_ads(): ps = parse_search_html(_fixture()) assert len(ps) == 12 # 광고 3 + 슈퍼적립 1 은 제외됨 assert all(p.source == "naver" for p in ps) assert all(p.price > 0 and p.name for p in ps) def test_seller_and_catalog_cards_are_both_kept(): ps = parse_search_html(_fixture()) catalog = [p for p in ps if p.mall_name == "네이버"] # "최저 N원" = 가격비교 seller = [p for p in ps if p.mall_name and p.mall_name != "네이버"] assert len(catalog) == 6 and len(seller) == 6 # 판매처 카드는 실제 몰명이 들어온다(빈 문자열/None 아님) assert all(len(p.mall_name) > 1 for p in seller) def test_shipping_is_captured(): """옛 오픈API 는 배송비 필드가 없어 전 소스 None 이었다 — 크롤의 이점이라 회귀로 막는다.""" ps = parse_search_html(_fixture()) assert all(p.shipping_fee is not None for p in ps) assert {p.shipping_type for p in ps} <= {"free", "paid"} assert any(p.shipping_type == "free" and p.shipping_fee == 0 for p in ps) assert any(p.shipping_type == "paid" and p.shipping_fee > 0 for p in ps) def test_price_is_never_the_shipping_fee(): """실측에서 실제로 밟은 버그: 가격 노드가 배송비까지 감싸 3,900원이 상품가로 잡혔다.""" ps = parse_search_html(_fixture()) assert not [p for p in ps if p.shipping_fee and p.price == p.shipping_fee] def test_detail_url_present(): ps = parse_search_html(_fixture()) assert all(p.detail_url and p.detail_url.startswith("http") for p in ps) # ── 가격 함정 회귀 (합성) ──────────────────────────────────────────────── def test_unit_price_is_not_mistaken_for_price(): """'21,900원(100ml당 548원)' → 21900. 단위가격을 먼저 떼지 않으면 548 을 집는다.""" html = _card("""
테스트 섬유유연제 4L
21,900원 (100ml당 548원) 테스트몰 상품 바로가기""") (p,) = parse_search_html(html) assert p.price == 21900 def test_delivery_fee_inside_price_node_is_excluded(): """가격비교 카드 구조: 가격 노드가 배송비를 품는다. 상품가 7,790 / 배송비 3,900 로 갈려야 한다.""" html = _card("""
커클랜드 드라이시트, 250매, 1개
최저7,790원 (1개입당 31원) 배송비3,900원 상품 바로가기""") (p,) = parse_search_html(html) assert p.price == 7790 assert p.shipping_fee == 3900 and p.shipping_type == "paid" assert p.mall_name == "네이버" # 판매처 표기가 없는 가격비교 카드 def test_list_price_and_discount_rate_are_ignored(): """'정상가60,000원 할인율38% 36,840원' → 36840.""" html = _card("""
다우니 미스티크 1L 6개
정상가60,000원 38%36,840원 공식몰 상품 바로가기""") (p,) = parse_search_html(html) assert p.price == 36840 def test_coupon_price_is_not_used_as_price(): """쿠폰할인가는 조건부(1인 1회·선착순)라 실구매가 하한으로 쓰면 최저가가 왜곡된다.""" html = _card("""
테스트 상품
21,900원 21,680원 쿠폰할인가 테스트몰 상품 바로가기""") (p,) = parse_search_html(html) assert p.price == 21900 def test_ad_card_family_is_dropped(): html = _card("""
광고 상품
9,900원 광고""", cls="adProduct_item_inner__abc") assert parse_search_html(html) == [] # ── 차단 판정 ──────────────────────────────────────────────────────────── def test_detect_block_flags_captcha_and_short_html(): assert detect_block("wtm_captcha", 0) == "wtm_captcha" assert detect_block("비정상적인 접근입니다", 0) == "비정상적인 접근" assert detect_block("" + "x" * 5000 + "", 0).startswith("short_html") # 결과가 있으면 차단이 아니다 — 짧아도 통과 assert detect_block("x", 3) is None def test_detect_block_passes_real_page(): assert detect_block(_fixture(), 12) is None # ── 신뢰 신호 ──────────────────────────────────────────────────────────── def test_trust_signals_are_captured(): """최저가는 '살 수 있는 가장 싼 값'이어야 한다 — 평점·리뷰가 그 판단 근거다.""" ps = parse_search_html(_fixture()) rated = [p for p in ps if p.review_count] assert rated, "리뷰가 있는 오퍼가 하나도 안 잡히면 셀렉터가 드리프트한 것" assert all(0 < p.rating <= 5 for p in rated) def test_missing_trust_stays_none_not_zero(): """리뷰 정보가 없는 것과 '리뷰 0개'는 다른 뜻이다 — 0 으로 채우면 구분이 사라진다.""" ps = parse_search_html(_fixture()) ghost = [p for p in ps if p.review_count is None] assert ghost, "픽스처에 미검증 오퍼가 있어야 이 계약을 지킬 수 있다" assert all(p.rating is None for p in ghost) def test_korean_abbreviated_review_count(): """'1.7만' 을 그대로 int() 하면 1 이 된다 — 만/천 단위를 풀어야 한다.""" html = _card("""
테스트 상품
10,000원
평점4.881.7만
상품 바로가기""") (p,) = parse_search_html(html) assert p.rating == 4.88 and p.review_count == 17000 def test_shipping_label_keeps_the_screen_wording(): """배송비 숫자만으론 '언제·어떤 조건'이 사라진다 — 화면 문구를 그대로 남겨야 근거가 된다.""" ps = parse_search_html(_fixture()) assert all(p.shipping_label for p in ps) assert any("배송비무료" in p.shipping_label for p in ps) assert any("도착" in p.shipping_label for p in ps) def test_shipping_label_survives_price_node_decompose(): """가격 노드에서 배송비를 decompose 하기 **전에** 라벨을 읽어야 한다. (실제로 나중에 추가했다가 가격비교 카드에서 라벨이 통째로 비었다)""" html = _card("""
커클랜드 드라이시트, 250매, 1개
최저7,790원 배송비3,900원 상품 바로가기""") (p,) = parse_search_html(html) assert p.shipping_label == "배송비3,900원" and p.shipping_fee == 3900