"""TourAPI 어댑터 — 값 매핑과 저작권 게이트.""" import pytest from common.category_schema import get_schema from common.enums import PlaceCategory from services.collector.tour_api_adapter import ( COMMERCIAL_OK_LICENSES, NO_DERIVATIVE_LICENSES, TourApiAdapter, ) ADAPTER = TourApiAdapter() # ── 실제 응답에서 복사한 표본 ────────────────────────────────────────────── _LODGING_INTRO = { "checkintime": "14:00", "checkouttime": "12:00", "refundregulation": "예약완료-숙박 6일 전까지 수수료 10% 환불
금액 100%", "chkcooking": "가능", "parkinglodging": "가능 (객실당 1대 무료 주차)", "pickup": "부분 가능", "barbecue": "1", "karaoke": "0", "roomcount": "13실", "scalelodging": "대지 면적 11,570㎡", "foodplace": "있음", } _ROOM_ROW = { "roomtitle": "황토방", "roomsize1": "12", "roomsize2": "39.6", "roombasecount": "2", "roommaxcount": "4", "roomoffseasonminfee1": "100000", "roomoffseasonminfee2": "120000", "roompeakseasonminfee1": "130000", "roomcook": "Y", "roomaircondition": "Y", "roombathfacility": "Y", "roomimg1": "http://tong.visitkorea.or.kr/a.jpg", "roomimg1alt": "황토방 5", "cpyrhtDivCd1": "Type3", "roomimg2": "http://tong.visitkorea.or.kr/b.jpg", "roomimg2alt": "황토방 4", "cpyrhtDivCd2": "Type2", } _RESTAURANT_INTRO = { "opentimefood": "- 08:30~20:00
- 준비시간 15:00~16:00", "restdatefood": "매주 수요일", "firstmenu": "삼선자장", "treatmenu": "게살해물요리 / 난자완스 / 특색냉채 등", "parkingfood": "불가 (인근 주차장 이용)", "packing": "가능", "kidsfacility": "0", "chkcreditcardfood": "없음", "smoking": "모두 금연석", "infocenterfood": "02-2608-5255", } def _map(facts): return {(f.key, f.unit_name): f.value for f in facts} # ── URL 라우팅 ──────────────────────────────────────────────────────────── @pytest.mark.parametrize("url,expected", [ ("https://korean.visitkorea.or.kr/detail/ms_detail.do?cotid=2708335", True), ("tour://32/2708335", True), ("tour://39/1052741", True), ("https://gangmunstay.kr/", False), ("https://m.place.naver.com/accommodation/1/home", False), ("https://www.yanolja.com/pension/1", False), ]) def test_can_handle(url, expected): """검증: 어떤 주소를 받는가.""" assert ADAPTER.can_handle(url) is expected # ── 부호 판정 (여기가 제일 위험한 자리) ──────────────────────────────────── @pytest.mark.parametrize("raw,expected", [ ("가능", True), ("가능 (객실당 1대 무료 주차)", True), ("부분 가능", True), ("불가", False), ("불가 (인근 주차장 이용)", False), # 뒤에 '이용' 이 있다고 true 가 되면 안 된다 ("없음", False), ("", None), ("문의", None), # 모르면 None — '아니오' 로 단정하지 않는다 ]) def test_head_bool_reads_leading_token_only(raw, expected): """검증: '가능/불가 (설명)' 형태의 부호 판정.""" assert ADAPTER._head_bool(raw) is expected def test_unknown_value_becomes_no_fact_not_false(): """검증: 판정할 수 없는 값.""" facts = _map(ADAPTER._lodging_facts({"chkcooking": "문의 바랍니다"})) assert ("cooking_allowed", None) not in facts # ── 숙박 매핑 ───────────────────────────────────────────────────────────── def test_lodging_place_facts(): """검증: 숙박 detailIntro2 → 사업장 fact.""" f = _map(ADAPTER._lodging_facts(_LODGING_INTRO)) assert f[("check_in_time", None)] == "14:00" assert f[("check_out_time", None)] == "12:00" assert f[("cooking_allowed", None)] == "true" assert f[("parking", None)] == "true" assert f[("pickup_service", None)] == "true" assert f[("bbq_available", None)] == "true" assert "
" not in f[("cancel_policy", None)] def test_lodging_drops_fields_without_schema_slot(): """검증: 스키마에 자리가 없는 TourAPI 필드(foodplace).""" facts = _map(ADAPTER._lodging_facts(_LODGING_INTRO)) values = set(facts.values()) assert "있음" not in values, "foodplace 는 자리가 없다 — 버려야 한다" # 원문 그대로가 아니라 스키마가 정한 모양으로 들어간다. assert facts[("total_rooms", None)] == "13", "'13실' 에서 숫자만 뽑는다(unit 은 스키마가 안다)" assert facts[("building_scale", None)] == "대지 면적 11,570㎡", ( "규모는 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다" ) def test_room_facts_are_unit_scoped_and_use_square_meters(): """검증: detailInfo2 → 객실 단위 fact.""" f = _map(ADAPTER._room_facts([_ROOM_ROW])) assert f[("room_type", "황토방")] == "황토방" assert f[("standard_capacity", "황토방")] == "2" assert f[("max_capacity", "황토방")] == "4" assert f[("room_size", "황토방")] == "39.6" # 12(평)이 아니다 assert f[("weekday_price", "황토방")] == "100000" assert f[("weekend_price", "황토방")] == "120000" assert f[("peak_price", "황토방")] == "130000" assert f[("has_kitchen", "황토방")] == "true" def test_room_facts_keys_exist_in_lodging_schema(): """검증: 객실 fact 의 key 가 전부 숙박 스키마에 있는가.""" schema = get_schema(PlaceCategory.LODGING) for fact in ADAPTER._room_facts([_ROOM_ROW]) + ADAPTER._lodging_facts(_LODGING_INTRO): spec = schema.get(fact.key) assert spec is not None, f"숙박 스키마에 없는 key: {fact.key}" assert spec.scope == fact.scope, f"{fact.key} 스코프 불일치: {spec.scope} != {fact.scope}" # ── 음식점 매핑 ─────────────────────────────────────────────────────────── def test_restaurant_facts(): """검증: 음식점 detailIntro2 → 사업장 fact.""" f = _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO)) assert f[("business_hours", None)].startswith("- 08:30~20:00") assert "
" not in f[("business_hours", None)] assert f[("closed_days", None)] == "매주 수요일" assert f[("signature_menu", None)] == "삼선자장" assert f[("parking", None)] == "false" assert f[("takeout", None)] == "true" def test_restaurant_never_maps_kids_facility_to_kids_allowed(): """검증: kidsfacility=0 인 음식점.""" assert ("kids_allowed", None) not in _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO)) def test_restaurant_never_maps_credit_card_flag_to_payment_methods(): """검증: chkcreditcardfood='없음'.""" assert ("payment_methods", None) not in _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO)) def test_treat_menu_splits_and_drops_trailing_marker(): """검증: treatmenu '게살해물요리 / 난자완스 / 특색냉채 등'.""" names = [f.unit_name for f in ADAPTER._restaurant_facts(_RESTAURANT_INTRO) if f.key == "menu_name"] assert names == ["게살해물요리", "난자완스", "특색냉채"] def test_restaurant_facts_keys_exist_in_schema(): """검증: 음식점 fact 의 key 가 전부 스키마에 있는가.""" schema = get_schema(PlaceCategory.RESTAURANT) for fact in ADAPTER._restaurant_facts(_RESTAURANT_INTRO): spec = schema.get(fact.key) assert spec is not None, f"음식점 스키마에 없는 key: {fact.key}" assert spec.scope == fact.scope # ── 저작권 게이트 (docs/DECISIONS.md 1-2) ───────────────────────────────── def test_license_constants_match_kogl(): """검증: 공공누리 유형 상수.""" assert COMMERCIAL_OK_LICENSES == {"type1", "type3"} assert NO_DERIVATIVE_LICENSES == {"type3", "type4"} def test_media_drops_non_commercial_license(): """검증: 같은 객실에 Type3 사진과 Type2 사진이 섞여 온다.""" media = ADAPTER._media({}, [], [_ROOM_ROW]) urls = [m.origin_url for m in media] assert "http://tong.visitkorea.or.kr/a.jpg" in urls # Type3 assert "http://tong.visitkorea.or.kr/b.jpg" not in urls # Type2 — 버려야 한다 assert all(m.license in COMMERCIAL_OK_LICENSES for m in media) def test_media_drops_unknown_license(): """검증: 저작권 유형이 비었거나 처음 보는 코드.""" rows = [{"roomtitle": "A", "roomimg1": "http://x/1.jpg", "cpyrhtDivCd1": ""}, {"roomtitle": "B", "roomimg1": "http://x/2.jpg", "cpyrhtDivCd1": "Type9"}] assert ADAPTER._media({}, [], rows) == [] def test_media_carries_license_so_no_derivative_can_be_honored(): """검증: 담긴 사진이 라이선스를 들고 나가는가.""" media = ADAPTER._media({}, [], [_ROOM_ROW]) assert media[0].license == "type3" assert media[0].license in NO_DERIVATIVE_LICENSES assert media[0].unit_name == "황토방" def test_media_keeps_room_photo_attached_to_its_room(): """검증: 객실 사진의 unit_name.""" assert {m.unit_name for m in ADAPTER._media({}, [], [_ROOM_ROW])} == {"황토방"} # ── 값 다듬기 ───────────────────────────────────────────────────────────── @pytest.mark.parametrize("raw,expected", [ ("13실", "13"), ("11,570㎡", "11570"), ("39.6", "39.6"), ("16명", "16"), ("", None), ("문의", None), ("0", None), ]) def test_number_strips_units_and_commas(raw, expected): """검증: '13실'·'11,570㎡' 같은 단위 붙은 숫자.""" assert ADAPTER._number(raw) == expected def test_plain_unwraps_br_but_keeps_negation_words(): """검증:
제거가 글자를 지우지 않는가.""" assert "불가" in ADAPTER._plain("주차
불가
합니다") assert "
" not in ADAPTER._plain("주차
불가") # ── 콘텐츠 조회 (services/external/tour_lookup.py) ──────────────────────── 네트워크를 타지 않는다. from services.external.tour_lookup import ( # noqa: E402 MAX_QUERY_ATTEMPTS, _name_matches, _query_candidates, normalize, ) def test_query_candidates_shortens_from_the_tail(): """검증: 등록명보다 긴 상호.""" got = _query_candidates("그래비티 조선 서울 판교 오토그래프 컬렉션") assert got[0] == "그래비티 조선 서울 판교 오토그래프 컬렉션" assert "그래비티 조선 서울 판교" in got assert len(got) <= MAX_QUERY_ATTEMPTS @pytest.mark.parametrize("name,expected", [ ("가재와곰펜션", ["가재와곰펜션"]), # 한 토큰이면 후보도 하나 ("", []), (" ", []), ]) def test_query_candidates_edge_cases(name, expected): """검증: 토큰이 하나거나 비어 있는 상호.""" assert _query_candidates(name) == expected @pytest.mark.parametrize("ours,theirs,match", [ ("그래비티 조선 서울 판교 오토그래프 컬렉션", "그래비티 조선 서울 판교", True), # 등록명이 더 짧다 ("가재와곰", "가재와곰펜션", True), # 등록명이 더 길다 ("롯데호텔 월드", "롯데호텔월드", True), # 공백만 다르다 ("롯데호텔 월드", "롯데호텔 부산", False), # 다른 지점 ("코트야드 바이 메리어트 서울 판교", "그래비티 조선 서울 판교", False), # 같은 동네 다른 호텔 ]) def test_name_matches(ours, theirs, match): """검증: 상호 대조.""" assert _name_matches(ours, theirs) is match def test_normalize_matches_naver_lookup_rule(): """검증: 상호 정규화 규칙.""" assert normalize("롯데호텔 월드") == "롯데호텔월드" assert normalize("A-1 Cafe & Bar") == "a1cafebar"