"""사이트 주소(네임스페이스) 확인·예약. 이 경로가 절대 하면 안 되는 것: - 한글·대문자 주소를 통과시키는 것 — 주소가 퍼센트 인코딩 덩어리가 되어 사장님이 불러줄 수 없다 - 확인은 통과시키고 저장에서 튕기는 것 — 규칙이 두 곳에 있으면 반드시 생긴다 - ★ 이미 발행돼 색인된 주소를 바꾸는 것 — AI 검색이 잡아 둔 페이지가 404 가 된다 """ import uuid import pytest from sqlalchemy import text from common.enums import ErrorType, SiteStatus from services import site_slug async def _place(client, headers, name="주소펜션"): r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1}) return r.json()["place"]["place_id"] async def _check(client, headers, pid, slug): return (await client.get(f"/v1/place/{pid}/site/slug/check", headers=headers, params={"slug": slug})).json() async def _reserve(client, headers, pid, slug): return (await client.post(f"/v1/place/{pid}/site/slug", headers=headers, json={"slug": slug})).json() @pytest.mark.parametrize( "slug, reason", [ ("doflo", None), ("stay-mumum-2", None), ("도플로", site_slug.REASON_FORMAT), # 한글 ("Doflo", site_slug.REASON_FORMAT), # 대문자 ("my_site", site_slug.REASON_FORMAT), # 언더스코어 ("a--b", site_slug.REASON_FORMAT), # 연속 하이픈(퓨니코드 접두 xn-- 와 헷갈린다) ("-doflo", site_slug.REASON_FORMAT), ("doflo-", site_slug.REASON_FORMAT), ("ab", site_slug.REASON_LENGTH), # 3자 미만 ("a" * 51, site_slug.REASON_LENGTH), # 50자 초과 ("admin", site_slug.REASON_RESERVED), ("api", site_slug.REASON_RESERVED), ("robots", site_slug.REASON_RESERVED), ("undefined", site_slug.REASON_RESERVED), # 프론트 버그가 그대로 주소가 되는 걸 막는다 ], ) def test_slug_rules(slug, reason): """검증: 형식·예약어 규칙(확인과 저장이 같이 쓰는 단 하나의 정의). 기대결과: 쓸 수 있으면 None, 아니면 사유 코드.""" assert site_slug.validate_slug(slug) == reason async def test_check_then_reserve(auth_headers, client): """검증: 주소를 확인하고 예약한다. 사이트 행이 없어도 만들어진다. 기대결과: available → 저장 → 자기 주소이므로 다시 확인해도 available.""" h = await auth_headers("slug1") pid = await _place(client, h) assert (await _check(client, h, pid, "doflo"))["available"] is True saved = await _reserve(client, h, pid, "doflo") assert saved["result"]["code"] == ErrorType.SUCCESS.value assert saved["site"]["domain"] == "doflo" # ★ 자기 자신은 중복이 아니다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다. again = await _check(client, h, pid, "doflo") assert again["available"] is True and "reason" not in again async def test_invalid_slug_is_refused_on_save_too(auth_headers, client): """검증: 확인에서 막힌 값은 저장에서도 막힌다(클라이언트 검증을 믿지 않는다). 기대결과: 두 경로가 같은 사유를 돌려준다.""" h = await auth_headers("slug2") pid = await _place(client, h) for slug, reason in (("도플로", site_slug.REASON_FORMAT), ("ab", site_slug.REASON_LENGTH), ("admin", site_slug.REASON_RESERVED)): checked = await _check(client, h, pid, slug) assert checked["available"] is False and checked["reason"] == reason saved = await _reserve(client, h, pid, slug) assert saved["result"]["success"] is False and saved["reason"] == reason assert "site" not in saved async def test_taken_by_other_place_offers_suggestion(auth_headers, client): """검증: 남이 쓰는 주소는 못 쓴다. 대신 쓸 수 있는 대안을 하나 준다. 기대결과: available=false / TAKEN / suggestion=doflo-2.""" h = await auth_headers("slug3") mine = await _place(client, h, "도플로") other = await _place(client, h, "도플로2호점") await _reserve(client, h, mine, "doflo") checked = await _check(client, h, other, "doflo") assert checked["available"] is False assert checked["reason"] == site_slug.REASON_TAKEN assert checked["suggestion"] == "doflo-2" saved = await _reserve(client, h, other, "doflo") assert saved["result"]["code"] == ErrorType.DB_ALREADY_SAME_KEY.value assert saved["suggestion"] == "doflo-2" # 제안대로면 통과한다 — 제안이 실제로 쓸 수 있는 값이어야 의미가 있다. assert (await _reserve(client, h, other, "doflo-2"))["site"]["domain"] == "doflo-2" async def test_published_site_slug_is_locked(auth_headers, client, db_engine): """검증: ★ 이미 발행된 사이트의 주소는 바꿀 수 없다. 기대결과: SITE_SLUG_LOCKED. 같은 값 재전송은 변경이 아니므로 통과.""" h = await auth_headers("slug4") pid = await _place(client, h) await _reserve(client, h, pid, "published-stay") # 발행 상태를 만든다(빌드 잡을 돌리는 대신 상태만) — 잠금은 published_at·status 가 근거다. 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)}, ) locked = await _reserve(client, h, pid, "published-stay-new") assert locked["result"]["code"] == ErrorType.SITE_SLUG_LOCKED.value assert locked["reason"] == site_slug.REASON_LOCKED # 같은 값 재전송은 변경이 아니다. same = await _reserve(client, h, pid, "published-stay") assert same["result"]["code"] == ErrorType.SUCCESS.value assert same["site"]["domain"] == "published-stay" async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): """검증: 남의 회사 사업장 주소는 확인도 예약도 못 한다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("slug5") intruder = await auth_headers("slug6", other_company_id) pid = await _place(client, h) assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert (await _reserve(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value