o2o-site-AEO/solution/backend/tests/test_site_template.py
Mina Choi c6908629f3 [feat] solution: 템플릿 정의를 templates.json 한 파일로 — 업종 없는 id · 레이아웃 basic/paper 정리
템플릿 정보가 빌더·렌더러·백엔드에 따로 적혀 어긋나 있었다 — 백엔드 기본값이 없는 id 를
가리켰고, 음식점 강조색 오타, 섹션 간격이 빌더와 서버에서 달랐다.

- shared/src/data/templates.json: 템플릿 4개(simple·magazine·retro·paper)와 업종별 허용·기본
- shared/lib/catalog.ts · backend/common/template_catalog.py: 같은 JSON 을 읽는다, 모르는 id 는 에러
- 저장·미리보기·발행에서 모르는 id 를 거절한다(site_service · site.py 422 · build_service 실패)
- site: 레이아웃 등록표(basic·paper) + LayoutProvider, Shell→Frame, HomePage→SectionList
- 연결 안 된 레이아웃 5개, 배치 고르기(variant), 서체 선택, 빌더 canvas/DevShowcase 삭제
- 빌더: 템플릿을 바꾸면 이전 템플릿이 켠 섹션을 끄고 안내 문구를 띄운다
- postgres-init/migrations/0023: stay-retro → retro, 병원 허용 밖은 NULL(운영 미적용)
- Dockerfile·worker: 백엔드 이미지에 shared/src/data 복사
- docs: TEMPLATES.md 신설(세 폴더 역할·템플릿 추가·렌더링 순서), DATA_MODEL·ARCHITECTURE 등 갱신

shared·site·frontend tsc 통과, site vitest 통과, 백엔드 DB 없는 테스트 41 passed(DB 테스트 미실행)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:57:35 +09:00

111 lines
4.7 KiB
Python

"""템플릿 선택 저장.
이 경로가 절대 하면 안 되는 것:
- 업종 허용 목록(templates.json)에 없는 템플릿을 저장하는 것.
- 발행됐다고 템플릿을 잠그는 것 — 디자인은 바뀌어도 URL이 그대로다.
- 바꿔 놓고 재빌드 표시를 안 하는 것.
"""
import uuid
from sqlalchemy import text
from common.enums import ErrorType, PlaceCategory, SiteStatus
from common.template_catalog import INDUSTRIES
from services.site_payload import to_site_payload
async def _place(client, headers, name="템플릿펜션", category=1):
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": category})
return r.json()["place"]["place_id"]
async def _set_template(client, headers, pid, template_id):
return (await client.post(f"/v1/place/{pid}/site/template", headers=headers,
json={"template_id": template_id})).json()
async def _get_site(client, headers, pid):
return (await client.get(f"/v1/place/{pid}/site", headers=headers)).json()
async def test_set_template_creates_site_row(auth_headers, client):
"""검증: 사이트 행이 없어도 템플릿을 먼저 고를 수 있고, 조회에도 그대로 나온다."""
h = await auth_headers("tpl1")
pid = await _place(client, h)
saved = await _set_template(client, h, pid, "paper")
assert saved["result"]["code"] == ErrorType.SUCCESS.value
assert saved["site"]["template_id"] == "paper"
assert (await _get_site(client, h, pid))["site"]["template_id"] == "paper"
async def test_unknown_template_is_refused(auth_headers, client):
"""검증: 등록되지 않은 id는 거절한다."""
h = await auth_headers("tpl2")
pid = await _place(client, h)
refused = await _set_template(client, h, pid, "stay-retro")
assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
assert "site" not in refused
async def test_template_outside_industry_list_is_refused(auth_headers, client):
"""검증: 등록된 템플릿이라도 그 업종 허용 목록에 없으면 거절한다(병원은 레트로를 못 쓴다)."""
h = await auth_headers("tpl3")
pid = await _place(client, h, name="템플릿의원", category=PlaceCategory.CLINIC.value)
refused = await _set_template(client, h, pid, "retro")
assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
async def test_empty_value_clears_to_default(auth_headers, client):
"""검증: 빈 값은 '고르지 않음'이다 — NULL로 돌아가 업종 기본 템플릿으로 나간다."""
h = await auth_headers("tpl4")
pid = await _place(client, h)
await _set_template(client, h, pid, "paper")
cleared = await _set_template(client, h, pid, "")
assert cleared["result"]["code"] == ErrorType.SUCCESS.value
assert "template_id" not in cleared["site"]
async def test_published_site_template_is_not_locked(auth_headers, client, db_engine):
"""검증: 발행된 사이트도 템플릿을 바꿀 수 있고, 재빌드 표시가 선다."""
h = await auth_headers("tpl5")
pid = await _place(client, h)
await _set_template(client, h, pid, "simple")
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE sites SET status = :st, published_at = now() WHERE place_id = :p"),
{"st": SiteStatus.PUBLISHED.value, "p": uuid.UUID(pid)},
)
changed = await _set_template(client, h, pid, "paper")
assert changed["result"]["code"] == ErrorType.SUCCESS.value
assert changed["site"]["template_id"] == "paper"
assert changed["needs_rebuild"] is True
async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 사업장의 템플릿은 바꿀 수 없다(PLACE_NOT_FOUND)."""
h = await auth_headers("tpl6")
intruder = await auth_headers("tpl7")
pid = await _place(client, h)
blocked = await _set_template(client, intruder, pid, "paper")
assert blocked["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
def test_payload_uses_saved_template_and_falls_back():
"""검증: payload의 templateId는 저장값, 없으면 업종 기본 템플릿이다."""
place = {"place_id": uuid.uuid4(), "category": PlaceCategory.LODGING.value, "name": "스테이,머뭄"}
snapshot = {"place": {"name": "스테이,머뭄", "category": PlaceCategory.LODGING.value}}
version = {"version": 1}
chosen = to_site_payload(place, snapshot, {"template_id": "paper"}, version, [])
assert chosen["theme"]["templateId"] == "paper"
fallback = to_site_payload(place, snapshot, {"template_id": None}, version, [])
assert fallback["theme"]["templateId"] == INDUSTRIES["stay"]["defaultTemplate"]