"""빌드 스냅샷 — ★ 사이트에 나가면 안 되는 것이 스냅샷에 들어오지 않는가.""" import uuid from sqlalchemy import text from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType from services.snapshot import build_snapshot async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING): pid = uuid.uuid4() async with db_engine.begin() as c: await c.execute( text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) " "VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"), {"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value, "addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"}, ) return pid async def _fact(db_engine, pid, key, value, status, unit_id=None): async with db_engine.begin() as c: await c.execute( text("INSERT INTO place_facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " "VALUES (:f,:p,:u,:k,:v,:s,:st,now())"), {"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": value, "s": SourceType.OWNER.value, "st": status.value}, ) async def _media(db_engine, pid, url, status, alt="설명"): 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, sort_order) " "VALUES (:m,:p,:u,:u,:s,:st,:a,0)"), {"m": uuid.uuid4(), "p": pid, "u": url, "s": SourceType.CRAWL.value, "st": status.value, "a": alt}, ) class _Place: def __init__(self, pid, category=PlaceCategory.LODGING): self.place_id = pid self.category = category.value self.name = "스냅샷펜션" self.road_address = "강원특별자치도 양양군 현북면 하조대3길 11" self.address = None self.phone = "033-000-0000" self.latitude = None self.longitude = None async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id): """검증: 여러 상태의 fact 를 섞어 넣는다.""" pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED) await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED) await _fact(db_engine, pid, "breakfast", "true", FactStatus.PENDING_OWNER) await _fact(db_engine, pid, "bbq_available", "true", FactStatus.REJECTED) await _fact(db_engine, pid, "smoking", "false", FactStatus.EXPIRED) snap = await build_snapshot(_Place(pid)) keys = {f["key"] for f in snap["facts"]} assert keys == {"check_in_time", "wifi"} async def test_only_approved_media_enters_snapshot(db_engine, owner_id): """검증: 승인/확인대기/반려 사진을 섞어 넣는다.""" pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED) await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW) await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED) snap = await build_snapshot(_Place(pid)) assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"] async def test_media_without_alt_is_excluded(db_engine, owner_id): """검증: 승인됐지만 alt 텍스트가 없는 사진.""" pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="") await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진") snap = await build_snapshot(_Place(pid)) assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"] async def test_fact_labels_come_from_category_schema(db_engine, owner_id): """검증: 스냅샷의 fact 라벨.""" pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) snap = await build_snapshot(_Place(pid)) f = snap["facts"][0] assert f["label"] == "체크인 시간" assert f["scope"] == "place" async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id): """검증: 객실 단위 fact.""" pid = await _seed(db_engine, owner_id) uid = uuid.uuid4() async with db_engine.begin() as c: await c.execute(text("INSERT INTO place_units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), {"u": uid, "p": pid, "n": "A동"}) await _fact(db_engine, pid, "max_capacity", "4", FactStatus.VERIFIED, unit_id=uid) snap = await build_snapshot(_Place(pid)) assert snap["units"][0]["name"] == "A동" assert snap["facts"][0]["unit_id"] == str(uid) assert snap["facts"][0]["scope"] == "unit" async def test_empty_place_gives_empty_snapshot(db_engine, owner_id): """검증: 아무것도 없는 사업장.""" pid = await _seed(db_engine, owner_id) snap = await build_snapshot(_Place(pid)) assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == [] assert snap["place"]["name"] == "스냅샷펜션" # ── 지역 정보 ───────────────────────────────────────────────────────────── async def _local(db_engine, region_code, kind, status, title="지역이야기", **cols): """지역 캐시(`area_contents`)에 **지역 단위** 항목 한 행.""" from common.enums import LocalContentType, LocalSource async with db_engine.begin() as c: await c.execute( text("INSERT INTO area_contents " "(local_content_id, region_code, content_type, source, title, body, status, kind, " " display_start_at, display_end_at, collected_at) " "VALUES (:i,:r,:ct,:src,:t,cast(:b as jsonb),:st,:k,:ds,:de,now())"), {"i": uuid.uuid4(), "r": region_code, "ct": LocalContentType.STORY.value, "src": LocalSource.LLM.value, "t": title, "b": '{"title":"%s"}' % title, "st": status.value, "k": kind, "ds": cols.get("display_start_at"), "de": cols.get("display_end_at")}, ) class _RegionPlace(_Place): """region_code 를 가진 사업장.""" def __init__(self, pid, region_code): super().__init__(pid) self.region_code = region_code async def test_only_published_local_content_enters_snapshot(db_engine, owner_id): """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.""" from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) # kind 를 달리 준다 — 한 지역에 같은 kind 는 한 행이다(uq_local_contents_kind). await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "발행이야기") await _local(db_engine, "4113500", "people", LocalContentStatus.REVIEW, "검수대기이야기") await _local(db_engine, "4113500", "chronicle", LocalContentStatus.ENDED, "종료이야기") snap = await build_snapshot(_RegionPlace(pid, "4113500")) assert [c["title"] for c in snap["local"]["contents"]] == ["발행이야기"] async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id): """검증: 발행됐지만 노출 기간을 벗어난 지역 정보.""" from datetime import datetime, timedelta, timezone from common.enums import LocalContentStatus now = datetime.now(timezone.utc) pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "지금이야기") await _local(db_engine, "4113500", "people", LocalContentStatus.PUBLISHED, "끝난이야기", display_end_at=now - timedelta(days=1)) await _local(db_engine, "4113500", "chronicle", LocalContentStatus.PUBLISHED, "아직이야기", display_start_at=now + timedelta(days=1)) snap = await build_snapshot(_RegionPlace(pid, "4113500")) assert [c["title"] for c in snap["local"]["contents"]] == ["지금이야기"] async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id): """검증: 지역 캐시는 region_code 로 묶인다.""" from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "우리지역이야기") await _local(db_engine, "5011025", "songs", LocalContentStatus.PUBLISHED, "남의지역이야기") snap = await build_snapshot(_RegionPlace(pid, "4113500")) assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역이야기"] async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id): """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.""" from common.enums import LocalContentStatus from services.external.naver import region_key derived = region_key(_Place("x").road_address) pid = await _seed(db_engine, owner_id) await _local(db_engine, derived, "songs", LocalContentStatus.PUBLISHED, "양양이야기") snap = await build_snapshot(_RegionPlace(pid, "")) assert snap["local"]["region_code"] == derived assert [c["title"] for c in snap["local"]["contents"]] == ["양양이야기"] async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id): """검증: 지역 코드도 읽을 만한 주소도 없는 사업장.""" from common.enums import LocalContentStatus pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "어딘가이야기") place = _RegionPlace(pid, "") place.road_address = None snap = await build_snapshot(place) # itineraries 는 항상 있는 키다 — 이 업장은 place_id 는 있으니 place_itineraries 를 조회하고, 아직 생성된 게 없어 빈 목록으로 온다. assert snap["local"] == {"region_code": None, "contents": [], "itineraries": []}