o2o-site-AEO/solution/backend/tests/test_blog_post.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

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

235 lines
10 KiB
Python

"""미니 블로그 — 문구 필터와 메일 승인."""
import uuid
from datetime import date, timezone
from sqlalchemy import text
from common.enums import JobType, LocalContentType, PlaceCategory, PostStatus, PostTopicKind, SiteStatus
from services import blog_service, site_payload
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]
def test_build_prompt_falls_back_to_stay_wording_for_unmapped_category():
"""업종별로 분기할 수 있는 구조만 갖춘다 — 숙소 외 업종은 아직 실구현 전이라 숙소 문구로 대체한다."""
stay = blog_service.build_prompt(
place_name="테스트펜션", region="군산", topic_kind=PostTopicKind.WEATHER.value,
material="맑음인 날", used_topics=[], place_category=PlaceCategory.LODGING.value,
)
restaurant = blog_service.build_prompt(
place_name="테스트식당", region="군산", topic_kind=PostTopicKind.WEATHER.value,
material="맑음인 날", used_topics=[], place_category=PlaceCategory.RESTAURANT.value,
)
assert "숙소" in stay
assert "숙소" in restaurant # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다
def _snapshot_with(*rows):
return {"local": {"region_code": "52130", "contents": list(rows)}}
def _festival_row(name, start, end):
return {"content_type": LocalContentType.FESTIVAL.value, "title": name,
"body": {"name": name, "eventstartdate": start, "eventenddate": end, "location": "군산 원도심"}}
def test_materials_follow_the_post_date():
"""게시일에 맞는 소재만 나온다 — 9월 글에 '한겨울'·'눈'·끝난 축제가 붙지 않는다."""
snapshot = _snapshot_with(
_festival_row("시간여행축제", "20261002", "20261004"), # 곧 열린다(9일 뒤)
_festival_row("벚꽃축제", "20260401", "20260405"),
_festival_row("겨울빛축제", "20261220", "20261231"), # 아직 한참 멀다
{"content_type": LocalContentType.ATTRACTION.value, "title": "경암동 철길마을",
"body": {"name": "경암동 철길마을", "description": "철길 옆 골목"}},
)
out = blog_service.materials(snapshot, date(2026, 9, 23))
keys = [key for _kind, key, _material in out]
assert keys[0] == "festival:2026:시간여행축제"
assert "2026.10.02 ~ 2026.10.04" in out[0][2]
assert not any("벚꽃" in k or "겨울빛" in k for k in keys)
assert "season:2026:초가을" in keys
assert not any(k.startswith("season:") and not k.endswith("초가을") for k in keys)
assert "nearby:경암동 철길마을" in keys
assert "weather:2026-09:비" in keys
assert not any(k.endswith(":눈") or k.endswith(":소나기") for k in keys)
def test_materials_allow_snow_in_winter_and_reuse_seasons_each_year():
winter = [key for _k, key, _m in blog_service.materials(_snapshot_with(), date(2027, 1, 10))]
assert "weather:2027-01:눈" in winter
assert "season:2027:한겨울" in winter
def test_build_prompt_tells_the_post_date():
prompt = blog_service.build_prompt(
place_name="테스트펜션", region="군산", topic_kind=PostTopicKind.SEASON.value,
material="초가을", used_topics=[], post_date=date(2026, 9, 23),
)
assert "게시일: 2026년 9월 23일(수) · 초가을" in prompt
async def test_generate_one_forwards_place_category_into_the_prompt(monkeypatch):
"""generate_one 은 place_category 를 build_prompt 로 그대로 넘긴다 — 링크는 여기서 붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면 게이트가 링크까지 세어 정상 문구도 버려진다)."""
from services.llm import provider
from services.llm.types import LlmResult, Usage
captured = {}
class FakeLlm:
DEFAULT_MODEL = "fake-model"
@staticmethod
def is_configured():
return True
@staticmethod
async def generate(client, model, *, prompt, temperature=0.2, **kwargs):
captured["prompt"] = prompt
return LlmResult(json=None, text=BODY, usage=Usage())
monkeypatch.setattr(provider, "active", lambda: FakeLlm)
result = await blog_service.generate_one(
place_name="테스트식당", region="군산", topic_kind=PostTopicKind.WEATHER.value,
material="맑음인 날", used_topics=[], place_category=PlaceCategory.RESTAURANT.value,
)
assert result == (BODY, "fake-model")
assert "숙소" in captured["prompt"] # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다
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 다.
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
# 발행된 사이트가 없으면(이 테스트) 자동 이동 없이 확인 문구만 보여준다.
assert "meta http-equiv=\"refresh\"" not in res.text
async def test_approve_page_redirects_to_the_published_blog_section(client, db_engine, owner_id):
"""승인 확인 화면은 5초 뒤 그 업장의 발행된 사이트(미니 블로그 자리)로 자동 이동한다."""
token, post_id = await _seed_post(db_engine, owner_id)
async with db_engine.begin() as conn:
place_id = (
await conn.execute(text("SELECT place_id FROM place_posts WHERE post_id=:p"), {"p": post_id})
).scalar_one()
await conn.execute(
text(
"INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"
),
{"sid": uuid.uuid4(), "pid": place_id, "dom": "blog-redirect-test", "st": SiteStatus.PUBLISHED.value},
)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert "올렸습니다" in res.text
expected_url = site_payload.publish_origin() + "/s/blog-redirect-test#blog"
assert f'<meta http-equiv="refresh" content="5;url={expected_url}">' in res.text
assert f"href='{expected_url}'" in res.text
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):
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