o2o-site-AEO/solution/backend/tests/test_blog_post.py
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

136 lines
6.2 KiB
Python

"""미니 블로그 — 문구 필터와 메일 승인.
★ 이 파일이 지키는 것:
- 확인되지 않은 주장(요금·시간·인원·전화)이 든 문구는 화면에 못 간다
- 같은 링크를 두 번 눌러도 두 번 올라가지 않는다
- 만료된 링크는 오류가 아니라 안내로 끝난다
★ 2026-09-17 이후 GET 이 곧 승인이다(사장님 지시: "승인은 바로 승인 되게 그 링크만
클릭하면"). 메일 클라이언트의 링크 미리 열기에 그대로 노출된다는 걸 알고도 택한 것 —
예전처럼 "GET 은 확인 화면만" 을 기대하는 테스트를 두지 않는다.
"""
import uuid
from datetime import timezone
from sqlalchemy import text
from common.enums import JobType, PlaceCategory, PostStatus, PostTopicKind
from services import blog_service
BODY = (
"비가 한 차례 지나간 뒤 마당 돌이 검게 젖었습니다. 이런 날에는 대청마루에 앉아 빗소리만 들어도 "
"하루가 지나갑니다. 우산은 현관에 넉넉히 두었으니 편하게 다녀오세요. 젖은 길은 미끄러우니 "
"편한 신발을 권합니다. 마당 평상은 비가 그치면 금세 마릅니다."
)
def test_filter_rejects_unverifiable_claims():
for bad, word in [
(BODY[:120] + " 주중 198,000원입니다.", "198,000원"),
(BODY[:120] + " 입실은 15:00 입니다.", "15:00"),
(BODY[:120] + " 문의는 010-1234-5678 로.", "010-1234-5678"),
(BODY[:120] + " 군산 최고의 숙소입니다.", "최고"),
]:
ok, reason = blog_service.is_publishable_body(bad)
assert ok is False
assert word in reason
def test_filter_accepts_a_plain_scene():
ok, reason = blog_service.is_publishable_body(BODY)
assert ok is True, reason
def test_filter_drops_same_topic_twice_in_one_batch():
rows = [
{"body": BODY, "topic_key": "festival:야행", "topic_kind": PostTopicKind.FESTIVAL.value},
{"body": BODY, "topic_key": "festival:야행", "topic_kind": PostTopicKind.FESTIVAL.value},
]
kept, dropped = blog_service.filter_drafts(rows)
assert len(kept) == 1
assert "주제 중복" in dropped[0][1]
async def _seed_post(db_engine, owner_id, *, status=PostStatus.SENT, expires_days=14):
place_id, post_id = uuid.uuid4(), uuid.uuid4()
token, token_hash, expires = blog_service.issue_token()
if expires_days < 0:
from common.utils.gtime import GTime
expires = GTime.AddDays(expires_days)
# ★ 함정: 이 시각은 naive-UTC 다. raw text() 로 바인딩하면 asyncpg 가 컬럼 타입 정보 없이
# 드라이버 기본값(로컬 시스템 시간대)으로 해석해 KST(UTC+9) 서버에서는 9시간 밀린다 —
# 14일짜리 옛 만료값에서는 안 드러났는데(그래도 미래), 자정 단위(issue_token 2026-09-17
# 개편)로 정밀해지자 자정 만료가 "9시간 전에 이미 만료"로 뒤집혔다. ORM 경로(update()/
# insert())는 컬럼의 DateTime(timezone=True) 프로세서를 타서 이 문제가 없다 — raw text()
# 로 timestamptz 컬럼에 naive datetime 을 바인딩할 때만 명시적으로 UTC 를 달아야 한다.
aware_expires = expires.replace(tzinfo=timezone.utc)
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:pid, :uid, :name, :cat, 1)"),
{"pid": place_id, "uid": owner_id, "name": "스테이,머뭄", "cat": PlaceCategory.LODGING.value},
)
await conn.execute(
text("INSERT INTO place_posts (post_id, place_id, body, topic_kind, topic_key, status, "
"approve_token_hash, token_expires_at) "
"VALUES (:id, :pid, :body, :kind, :key, :st, :hash, :exp)"),
{"id": post_id, "pid": place_id, "body": BODY, "kind": PostTopicKind.WEATHER.value,
"key": f"weather:{post_id.hex[:6]}", "st": status.value, "hash": token_hash, "exp": aware_expires},
)
return token, post_id
async def _status(db_engine, post_id) -> int:
async with db_engine.begin() as conn:
row = await conn.execute(text("SELECT status FROM place_posts WHERE post_id = :id"), {"id": post_id})
return row.scalar()
async def test_get_approves_immediately(client, db_engine, owner_id):
token, post_id = await _seed_post(db_engine, owner_id)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert "올렸습니다" in res.text
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async def test_approve_link_is_single_use(client, db_engine, owner_id):
token, post_id = await _seed_post(db_engine, owner_id)
first = await client.get(f"/v1/site/post/approve?t={token}")
second = await client.get(f"/v1/site/post/approve?t={token}")
assert "올렸습니다" in first.text
assert "처리할 수 없는 링크" in second.text
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async def test_expired_link_is_an_answer_not_an_error(client, db_engine, owner_id):
token, post_id = await _seed_post(db_engine, owner_id, expires_days=-1)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert "처리할 수 없는 링크" in res.text
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_approve_enqueues_build_with_owner_user_id(client, db_engine, owner_id):
"""회귀: BUILD 잡 payload 에 owner_user_id 가 없으면 build_service.run_build 가
payload["owner_user_id"] 에서 KeyError 로 죽는다 — 승인 클릭이 실제로는 사이트를
재발행하지 않는 채로 '올렸습니다' 만 보여주고 있었다."""
token, post_id = await _seed_post(db_engine, owner_id)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert "올렸습니다" in res.text
async with db_engine.begin() as conn:
row = await conn.execute(
text("SELECT payload FROM jobs WHERE job_type = :jt ORDER BY created_at DESC LIMIT 1"),
{"jt": JobType.BUILD.value},
)
payload = row.scalar()
assert payload["owner_user_id"] == owner_id