o2o-site-AEO/solution/backend/tests/test_showcase_api.py
Mina Choi e8cda02a4b [feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
  그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
  isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
  (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
  통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)

전체 562 passed
2026-09-03 09:41:10 +09:00

92 lines
4.7 KiB
Python

"""공개 쇼케이스 목록 — 랜딩이 로그인 없이 부르는 유일한 사이트 조회.
이 경로가 절대 하면 안 되는 것:
- 발행되지 않은 사이트를 보여주는 것 — 열면 404 다.
- 사업장 식별자·전화번호·상세 주소를 흘리는 것. 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다.
"""
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from common.enums import PlaceCategory, PlaceStatus, SiteStatus
async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0):
"""places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다."""
pid, sid = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) "
"VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"),
{"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value,
"st": PlaceStatus.PUBLISHED.value,
"road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2",
"phone": "033-672-0000"},
)
await c.execute(
text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url, published_at) "
"VALUES (:s,:p,:d,:st,:t,:at)"),
{"s": sid, "p": pid, "d": domain, "st": status, "t": thumb,
"at": datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)},
)
return str(pid)
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id):
"""검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다.
기대결과: 발행본만 나온다."""
await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value,
domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg")
await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet")
res = await client.get("/v1/showcase")
assert res.status_code == 200
items = res.json()["items"]
assert [i["name"] for i in items] == ["하조대펜션"]
assert items[0]["url"] == "/s/hajodae"
assert items[0]["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/hajodae.jpg"
assert items[0]["category"] == PlaceCategory.LODGING.value
async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id):
"""검증: 응답 항목의 키를 그대로 본다.
기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다."""
await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae")
item = (await client.get("/v1/showcase")).json()["items"][0]
assert set(item) <= {"name", "category", "region", "url", "thumbnail_url"}
assert item["region"] == "강원특별자치도 양양군"
# 번지·전화번호가 어디로도 새지 않았는지 통째로 확인한다.
body = (await client.get("/v1/showcase")).text
assert "033-672-0000" not in body
assert "하조대3길" not in body
async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id):
"""★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나).
그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다."""
await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic")
item = (await client.get("/v1/showcase")).json()["items"][0]
assert item["name"] == "그림없는집"
assert "thumbnail_url" not in item
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id):
await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
items = (await client.get("/v1/showcase")).json()["items"]
assert [i["name"] for i in items] == ["나중", "먼저"]
items = (await client.get("/v1/showcase?limit=1")).json()["items"]
assert [i["name"] for i in items] == ["나중"]
# 상한 밖은 거부한다 — 전체 명단을 한 번에 긁어가는 요청을 열어 두지 않는다.
assert (await client.get("/v1/showcase?limit=999")).status_code == 422