"""발행 사이트 메타 태그 키워드(SiteOntology). 이 경로가 절대 하면 안 되는 것: - 이 가게 자료에 없는 낱말이 든 키워드를 메타 태그·제목에 싣는 것 (확인 안 된 시설 · 다른 권역 · 가격 주장) - SiteOntology 실패로 발행을 막는 것 — 키워드는 곁들이다 - 해석되지 않은 업체의 추천(입력 문자열로 검색한 결과)을 쓰는 것 - 숙박이 아닌 업종에 펜션 키워드를 붙이는 것 ★ MATCH 의 matches 1~10 · byLane 은 실제 응답에서 옮겼다 — 2026-09-14 로컬 SiteOntology(:3100) 에 스테이머뭄 프로필(features 독채·프라이빗, audiences 커플·가족, nearby 말랭이마을·동국사)을 보낸 결과. 11~13 은 같은 사전에 실제로 있는 단어로, 거르기 규칙을 보려고 붙였다. """ 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·지역 정보가 든 숙박 스냅샷. 기대결과: 있음(true)은 features, 모름(fact 없음)은 unverified, 없음(false)은 어디에도 없다.""" 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개)에 없는 시·군. 기대결과: 지어내지 않고 None — 없는 키를 보내면 SiteOntology 가 500 이다.""" 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_자료가_뒷받침하는_키워드만_남긴다(): """검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다. 기대결과: 마당·복층·커플·가족·프라이빗(확인 안 됨), 선유도(다른 권역), 최저가(가격 주장), 질문형, 보류(hold)가 모두 빠진다. 순위는 SiteOntology 순서 그대로.""" 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"): # 실측: region 표에 없는 키 → 외래키 위반 → 500 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 가 켜진 채로 숙박 사업장을 발행한다. 기대결과: 스냅샷으로 만든 프로필이 나가고, 거른 키워드가 payload.seo 로 실린다.""" 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