"""발행 사이트 메타 태그 키워드(SiteOntology).""" import json import uuid import httpx import pytest from sqlalchemy import text from common.enums import JobStatus, LocalContentType, MediaStatus, PlaceCategory, SiteStatus, SourceType from crud.job_crud import JobQueue from services import seo_keywords from services.external import site_ontology from worker.handlers import build_handler from worker.runner import Worker def _kw(canonical, category, intent="local", status="ok"): return {"canonical": canonical, "category": category, "intent": intent, "kind": "keyword", "status": status} MATCH = { "mode": "fusion", "matches": [ _kw("군산 펜션 독채", "시설"), _kw("군산 독채펜션", "코어"), _kw("군산 독채 마당 펜션", "롱테일"), _kw("군산 독채 복층 펜션", "롱테일"), _kw("군산 커플 프라이빗 펜션", "롱테일"), _kw("군산 독채펜션 예약", "코어", intent="transactional"), _kw("군산에 독채 펜션 있나요", "질문형", intent="informational"), _kw("군산 가족 독채펜션", "동반자"), _kw("말랭이마을 근처 숙소", "관광지"), _kw("군산 프라이빗한 독채", "분위기"), _kw("선유도 독채펜션", "권역"), _kw("군산 펜션 최저가", "의도", intent="transactional"), _kw("군산 주차 펜션", "시설", status="hold"), ], "byLane": [ {"key": "type", "label": "유형", "items": [ _kw("군산 펜션 독채", "시설"), _kw("군산 독채펜션", "코어"), _kw("군산 독채 마당 펜션", "롱테일"), ]}, {"key": "place", "label": "위치", "items": [_kw("말랭이마을 근처 숙소", "관광지")]}, ], } def _fact(key, value, unit_id=None): return {"key": key, "value": value, "scope": "unit" if unit_id else "place", "unit_id": unit_id, "status": 3} def _snapshot(**place): return { "place": { "name": "스테이,머뭄", "category": PlaceCategory.LODGING.value, "road_address": "전북특별자치도 군산시 절골길 18", "address": None, **place, }, "facts": [ _fact("intro", "군산 원도심 말랭이마을 옆 독채 스테이.\n두 번째 문단은 싣지 않는다."), _fact("bbq_available", "false"), _fact("parking", "true"), _fact("room_type", "독채", unit_id="u1"), _fact("max_capacity", "4", unit_id="u1"), _fact("max_capacity", "2", unit_id="u2"), ], "local": {"contents": [ {"content_type": LocalContentType.ATTRACTION.value, "title": "말랭이마을", "body": {"name": "말랭이마을"}}, {"content_type": LocalContentType.RESTAURANT.value, "title": "이성당", "body": {"name": "이성당"}}, {"content_type": LocalContentType.ATTRACTION.value, "title": "동국사", "body": {}}, ]}, } # ── 요청 만들기 ───────────────────────────────────────────────────────────── def test_스냅샷으로_업체_프로필을_만든다(): """검증: 확인된 fact·지역 정보가 든 숙박 스냅샷.""" merchant = seo_keywords.build_merchant("place-1", _snapshot()) assert merchant == { "externalId": "place-1", "name": "스테이,머뭄", "industryId": "stay.pension", "regionId": "kr.jeonbuk.gunsan", "description": "군산 원도심 말랭이마을 옆 독채 스테이.", "profile": { "address": "전북특별자치도 군산시 절골길 18", "capacity": {"max": 4}, "features": ["주차", "독채"], # 음식점(이성당)은 주변 관광지가 아니다. "nearby": ["말랭이마을", "동국사"], # 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다. "unverified": ["애견동반", "조식"], }, } def test_숙박이_아니면_부르지_않는다(): assert seo_keywords.build_merchant("place-1", _snapshot(category=PlaceCategory.CAFE.value)) is None def test_표에_없는_지역은_지역을_비운다(): """검증: SiteOntology 지역 표(54개)에 없는 시·군.""" merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1")) assert merchant["regionId"] is None def test_같은_이름의_시군은_시도로_가른다(): assert seo_keywords.region_key("강원특별자치도 고성군 토성면 1") == "kr.gangwon.goseong" assert seo_keywords.region_key("경상남도 고성군 고성읍 1") is None # ── 거르기 ───────────────────────────────────────────────────────────────── def test_자료가_뒷받침하는_키워드만_남긴다(): """검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다.""" merchant = seo_keywords.build_merchant("place-1", _snapshot()) seo = seo_keywords.select(MATCH, merchant) assert seo["keywords"] == ["군산 펜션 독채", "군산 독채펜션", "군산 독채펜션 예약", "말랭이마을 근처 숙소"] def test_제목_키워드는_유형_레인의_코어를_고른다(): merchant = seo_keywords.build_merchant("place-1", _snapshot()) assert seo_keywords.select(MATCH, merchant)["titleKeyword"] == "군산 독채펜션" def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다(): merchant = seo_keywords.build_merchant("place-1", _snapshot()) result = {"matches": [], "byLane": [{"key": "type", "items": [ _kw("군산 독채펜션 예약", "코어", intent="transactional"), _kw("독채펜션", "태그"), _kw("군산 펜션 독채", "시설"), ]}]} assert seo_keywords.select(result, merchant) == {"keywords": [], "titleKeyword": "군산 펜션 독채"} def test_업종어뿐인_단어는_싣지_않는다(): merchant = seo_keywords.build_merchant("place-1", _snapshot()) result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []} assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"] def test_표기만_다른_중복은_하나로(): merchant = seo_keywords.build_merchant("place-1", _snapshot()) result = {"matches": [_kw("군산 독채펜션", "코어"), _kw("군산 독채 펜션", "태그")], "byLane": []} assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"] # ── 클라이언트 ──────────────────────────────────────────────────────────── def _response(status, body, url): return httpx.Response(status, json=body, request=httpx.Request("POST", url)) @pytest.fixture def ontology_url(monkeypatch): monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "http://onto.test/") def _fake_ontology(sent, *, reject_region=False, resolve=True): async def fake_post(self, url, json=None, **kwargs): sent.append((url, json)) if url.endswith(site_ontology.PUBLISH_PATH): if reject_region and json.get("regionId"): return _response(500, {"statusCode": 500, "message": "Internal server error"}, url) return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url) resolved = {"externalId": json["query"]} if resolve else None return _response(201, {**MATCH, "resolved": resolved}, url) return fake_post async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontology_url, monkeypatch): sent = [] monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent)) merchant = seo_keywords.build_merchant("place-1", _snapshot()) await site_ontology.match_for_merchant(merchant) assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"] # generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다. assert sent[0][1] == {**merchant, "generate": False} assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT} async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontology_url, monkeypatch): sent = [] monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent, reject_region=True)) merchant = seo_keywords.build_merchant("place-1", _snapshot()) await site_ontology.match_for_merchant(merchant) publishes = [body for url, body in sent if url.endswith(site_ontology.PUBLISH_PATH)] assert [b["regionId"] for b in publishes] == ["kr.jeonbuk.gunsan", None] assert sent[-1][0].endswith(site_ontology.MATCH_PATH) async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch): """검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과).""" monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False)) merchant = seo_keywords.build_merchant("place-1", _snapshot()) with pytest.raises(site_ontology.SiteOntologyError): await site_ontology.match_for_merchant(merchant) assert await seo_keywords.fetch("place-1", _snapshot()) is None async def test_주소가_비면_부르지_않는다(monkeypatch): monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "") async def boom(self, url, json=None, **kwargs): raise AssertionError("설정이 비었는데 SiteOntology 를 불렀다") monkeypatch.setattr(httpx.AsyncClient, "post", boom) assert await seo_keywords.fetch("place-1", _snapshot()) is None async def test_연결이_안_되면_예외를_올리지_않는다(ontology_url, monkeypatch): async def refused(self, url, json=None, **kwargs): raise httpx.ConnectError("connection refused", request=httpx.Request("POST", url)) monkeypatch.setattr(httpx.AsyncClient, "post", refused) assert await seo_keywords.fetch("place-1", _snapshot()) is None # ── 발행 한 바퀴 ─────────────────────────────────────────────────────────── REQUIRED = { "check_in_time": "15:00", "check_out_time": "11:00", "cancel_policy": "이용 7일 전 100% 환불, 3일 전 50% 환불", "cooking_allowed": "true", "pet_allowed": "false", } async def _published_place(client, h, db_engine): pid = (await client.post("/v1/place", headers=h, json={"name": "하조대독채", "category": 1})).json()["place"]["place_id"] await client.post(f"/v1/place/{pid}/verify", headers=h, json={ "source": 2, "road_address": f"강원특별자치도 양양군 현북면 하조대3길 {uuid.uuid4().hex[:3]}", "phone": "033-672-0000", "latitude": "38.0219217", "longitude": "128.7221449", }) for k, v in {**REQUIRED, "intro": "하조대 해변 도보 3분 거리의 독채 펜션입니다.", "facilities": "독채"}.items(): await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v}) async with db_engine.begin() as c: await c.execute( text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) " "VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"), {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg", "s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": "2층 목조 건물 외관", "l": "외관"}, ) return pid async def _build(client, h, pid) -> dict: job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"] assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] assert job["status"] == JobStatus.DONE.value, job.get("last_error") return job["result"] async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch): """검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다.""" h = await auth_headers("u1") pid = await _published_place(client, h, db_engine) sent = [] async def fake_match(merchant, limit=site_ontology.DEFAULT_LIMIT): sent.append(merchant) return {"resolved": {"externalId": merchant["externalId"]}, "matches": [_kw("양양 독채펜션", "코어"), _kw("양양 독채 마당 펜션", "롱테일")], "byLane": [{"key": "type", "items": [_kw("양양 독채펜션", "코어")]}]} monkeypatch.setattr(site_ontology, "match_for_merchant", fake_match) r = await _build(client, h, pid) assert r["published"] is True assert sent[0]["externalId"] == pid assert sent[0]["regionId"] == "kr.gangwon.yangyang" assert "독채" in sent[0]["profile"]["features"] payload = json.loads(open(r["payload_path"], encoding="utf-8").read()) assert payload["seo"] == {"keywords": ["양양 독채펜션"], "titleKeyword": "양양 독채펜션"} assert r["seo"] == payload["seo"] async def test_SiteOntology_가_죽어도_발행된다(auth_headers, client, db_engine, ontology_url, monkeypatch): h = await auth_headers("u1") pid = await _published_place(client, h, db_engine) async def down(merchant, limit=site_ontology.DEFAULT_LIMIT): raise site_ontology.SiteOntologyError("ConnectError: connection refused") monkeypatch.setattr(site_ontology, "match_for_merchant", down) r = await _build(client, h, pid) assert r["published"] is True assert "seo" not in r site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() assert site["site"]["status"] == SiteStatus.PUBLISHED.value payload = json.loads(open(r["payload_path"], encoding="utf-8").read()) assert "seo" not in payload