템플릿 정보가 빌더·렌더러·백엔드에 따로 적혀 어긋나 있었다 — 백엔드 기본값이 없는 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>
111 lines
4.7 KiB
Python
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"]
|