o2o-site-AEO/solution/backend/tests/test_social.py
hbyang e9849584c4 [feat] solution/backend: SNS 캡션 첫 문장에 지역·업종·상호
겨냥하는 것이 AI 검색 노출인데 캡션에 그 단서가 하나도 없었다. "군산 숙소" 같은
지역+업종은 실제 검색어이고 AI 가 인용할 때 잡는 말이다.

★ 해시태그가 아니라 문장에 녹인다. AI 는 태그가 아니라 문장을 읽고, 이 글은
사장님 개인 계정으로 나가는 발화라(DECISIONS 8) 태그 나열은 그 전제와 어긋난다.
기존 해시태그 금지는 그대로 둔다.

★ 지역·업종은 fact 가 아니라 카카오 로컬 검증을 통과한 place 값이다. ground_check
근거에 같이 얹는다 — 안 얹으면 본문에 쓴 순간 '근거 없는 주장' 으로 반려되고
3회 재시도를 태우고 실패한다. 상호명을 그렇게 다루던 방식 그대로다.

- prompts/social: caption_category("숙박업소"→숙소) · caption_region(
  "전북특별자치도 군산시"→"전북 군산"). ★ copy.py 라벨과 region_label() 을 그대로
  쓰지 않는다 — 저쪽은 홈페이지 본문·쇼케이스 목록용이라 그 자리에선 그게 맞다
- 표에 없는 시·도는 그대로 둔다 — 줄이려다 엉뚱한 지역이 되는 것보다 낫다
- 값이 없으면 그 말만 빠지고 나머지는 그대로 나간다

실모델 확인: "전북 군산의 숙소 스테이,머뭄은…" weighted 141/500(전 120).
test_social 27 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 15:43:49 +09:00

717 lines
30 KiB
Python

import json
import uuid
from urllib.parse import parse_qs, urlparse
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import text
from crud import social_crud
from services import social_service as service
from services.external import gemini_text, threads
from services.external.social import SocialOutcomeUnknown, weighted_length
async def seed(client, auth_headers, db_engine):
h = await auth_headers("social-owner")
pid = uuid.UUID(
(
await client.post(
"/v1/place", headers=h, json={"name": "소식숙소", "category": 1}
)
).json()["place"]["place_id"]
)
async with db_engine.begin() as c:
uid = (
await c.execute(
text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": pid}
)
).scalar_one()
version = uuid.uuid4()
await c.execute(
text(
"INSERT INTO sites(site_id,place_id,domain,status,current_version_id) VALUES (:id,:p,'social-stay',3,:v)"
),
{"id": uuid.uuid4(), "p": pid, "v": version},
)
await c.execute(
text(
"INSERT INTO place_facts(fact_id,place_id,key,value,source_type,status) VALUES (:id,:p,'check_in_time','15:00',1,3)"
),
{"id": uuid.uuid4(), "p": pid},
)
return h, pid, uid, version
async def pending(db_engine, pid, uid, version, expired=False):
post_id, account_id = uuid.uuid4(), uuid.uuid4()
token = "a" * 43
async with db_engine.begin() as c:
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": account_id, "u": uid},
)
await c.execute(
text("""INSERT INTO place_social_posts(post_id,place_id,user_id,site_version_id,account_id,provider,body,link_url,status,approval_token_sha,approval_expires_at)
VALUES (:id,:p,:u,:v,:a,2,'체크인은 15:00입니다.',:url,'PENDING_APPROVAL',:sha,:expires)"""),
{
"id": post_id,
"p": pid,
"u": uid,
"v": version,
"a": account_id,
"sha": service.sha(token),
"url": service.site_payload.publish_origin() + "/s/social-stay",
"expires": datetime.now(timezone.utc)
+ timedelta(hours=-1 if expired else 1),
},
)
return post_id, token
async def test_draft_dedup_owner_scope(client, auth_headers, db_engine):
h, pid, uid, v = await seed(client, auth_headers, db_engine)
first = await client.post(f"/v1/social/place/{pid}/draft", headers=h, json={})
assert first.status_code == 200, first.text
second = await client.post(f"/v1/social/place/{pid}/draft", headers=h, json={})
assert second.json()["post_id"] == first.json()["post_id"]
assert (
first.json()["link_url"]
== service.site_payload.publish_origin() + "/s/social-stay"
)
other = await auth_headers("social-other")
response = await client.get(f"/v1/social/place/{pid}", headers=other)
assert (
response.status_code == 404 and response.json()["detail"] == "PLACE_NOT_FOUND"
)
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=9"))
).scalar_one() == 1
async def test_social_job_types_dispatch_to_the_right_handler():
from common.enums import JobType
from services.social_service import run_draft, run_post
from worker.handlers import HANDLERS
assert HANDLERS[JobType.SOCIAL_DRAFT.value] is run_draft
assert HANDLERS[JobType.SOCIAL_POST.value] is run_post
async def test_draft_and_post_jobs_enqueue_with_dispatchable_job_types(
client, auth_headers, db_engine, monkeypatch
):
"""create_draft 가 넣는 job_type 이 실제로 run_draft 로, decide 가 넣는 job_type 이 실제로 run_post 로 배달되는지 엔드투엔드로 확인한다(위 테스트의 정적 대조를 실제 큐 삽입 값으로 한 번 더 검증)."""
from worker.handlers import HANDLERS
monkeypatch.setattr(service, "posting_enabled", lambda: True)
h, pid, uid, v1 = await seed(client, auth_headers, db_engine)
draft_res = await client.post(f"/v1/social/place/{pid}/draft", headers=h, json={})
assert draft_res.status_code == 200, draft_res.text
draft_post_id = draft_res.json()["post_id"]
async with db_engine.begin() as c:
draft_job_type = (
await c.execute(
text("SELECT job_type FROM jobs WHERE dedupe_key LIKE :k"),
{"k": f"social:%:{draft_post_id}"},
)
).scalar_one()
assert HANDLERS[draft_job_type] is service.run_draft
v2 = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("UPDATE sites SET current_version_id=:v WHERE place_id=:p"),
{"v": v2, "p": pid},
)
post_id, token = await pending(db_engine, pid, uid, v2)
approve = await client.post(
f"/v1/social/approval/{post_id}/decision", json={"t": token, "approve": True}
)
assert approve.json()["applied"] is True
async with db_engine.begin() as c:
post_job_type = (
await c.execute(
text("SELECT job_type FROM jobs WHERE dedupe_key LIKE :k"),
{"k": f"social:%:{post_id}"},
)
).scalar_one()
assert HANDLERS[post_job_type] is service.run_post
async def test_requires_fixed_domain(client, auth_headers, db_engine):
h, pid, uid, v = await seed(client, auth_headers, db_engine)
async with db_engine.begin() as c:
await c.execute(
text("UPDATE sites SET domain=NULL WHERE place_id=:p"), {"p": pid}
)
assert (
await client.post(f"/v1/social/place/{pid}/draft", headers=h, json={})
).status_code == 409
async def test_prefetch_read_only_one_time_cas(client, auth_headers, db_engine):
h, pid, uid, v = await seed(client, auth_headers, db_engine)
post_id, token = await pending(db_engine, pid, uid, v)
for _ in range(2):
res = await client.get(f"/v1/social/approval/{post_id}?t={token}")
assert res.status_code == 200 and res.json()["status"] == "PENDING_APPROVAL"
assert res.headers["cache-control"] == "no-store"
assert "approval_token_sha" not in res.text
first = await client.post(
f"/v1/social/approval/{post_id}/decision", json={"t": token, "approve": True}
)
second = await client.post(
f"/v1/social/approval/{post_id}/decision", json={"t": token, "approve": True}
)
assert first.json()["applied"] is True, first.text
assert second.json()["applied"] is False
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=10"))
).scalar_one() == 1
async def test_expired_and_stale_sweep(client, auth_headers, db_engine):
h, pid, uid, v = await seed(client, auth_headers, db_engine)
post_id, token = await pending(db_engine, pid, uid, v, expired=True)
res = await client.post(
f"/v1/social/approval/{post_id}/decision", json={"t": token, "approve": False}
)
assert res.json()["applied"] is False
await social_crud.sweep()
async with db_engine.begin() as c:
row = (
await c.execute(
text("SELECT status,body FROM place_social_posts WHERE post_id=:p"),
{"p": post_id},
)
).first()
assert row.status == "EXPIRED" and row.body
await c.execute(
text(
"UPDATE place_social_posts SET status='POSTING',updated_at=now()-interval '11 minutes' WHERE post_id=:p"
),
{"p": post_id},
)
await social_crud.sweep()
async with db_engine.begin() as c:
assert (
await c.execute(
text("SELECT status FROM place_social_posts WHERE post_id=:p"),
{"p": post_id},
)
).scalar_one() == "UNKNOWN"
async def test_no_facts_no_paid_call(monkeypatch):
from services.llm import provider as llm_provider
def forbidden():
raise AssertionError("paid call")
monkeypatch.setattr(llm_provider, "active", forbidden)
with pytest.raises(gemini_text.GeminiInvalidOutput, match="NO_GROUNDED_FACTS"):
await gemini_text.generate_social_post("숙소", [], "https://example.com/s/stay")
async def test_long_draft_regenerates(monkeypatch):
"""generate_social_post 는 LLM_PROVIDER 추상화(services/llm/provider.py)를 탄다 — 여기서는 provider.active() 가 돌려주는 공급자 자체를 가짜로 바꿔 길이 초과 → 재요청 → 통과 흐름만 검증한다."""
from services.llm import provider as llm_provider
from services.llm.types import LlmResult, Usage
bodies = iter(["가" * 501, "체크인은 15:00입니다."])
class _FakeLlm:
__name__ = "services.llm.fake"
DEFAULT_MODEL = "fake-model"
@staticmethod
def is_configured():
return True
@staticmethod
async def generate(client, model, *, prompt, response_schema=None, temperature=0.2, max_retries=0, images=None):
body = next(bodies)
return LlmResult(
json={"body": body, "fact_keys": ["check_in_time"]},
text=json.dumps({"body": body, "fact_keys": ["check_in_time"]}),
usage=Usage(input_tokens=0, output_tokens=0),
)
monkeypatch.setattr(llm_provider, "active", lambda: _FakeLlm())
result = await gemini_text.generate_social_post(
"숙소",
[gemini_text.FactInput(key="check_in_time", label="체크인", value="15:00")],
"https://example.com/s/stay",
)
assert result == "체크인은 15:00입니다.\n\nhttps://example.com/s/stay"
def test_lengths():
assert weighted_length("한글", 1) == 4
assert weighted_length("https://example.com/" + "a" * 200, 1) == 23
assert weighted_length("한글", 2) == 2
assert weighted_length("https://example.com/" + "a" * 200, 2) == 220
async def test_threads_timeout_no_retry():
calls = []
def handler(req):
calls.append(req)
if req.url.path.endswith("/me/threads"):
assert (
b"media_type=TEXT" in req.content
and b"auto_publish_text=false" in req.content
)
assert b"image_url" not in req.content
return httpx.Response(200, json={"id": "11"})
raise httpx.ReadTimeout("lost", request=req)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(SocialOutcomeUnknown):
await threads.publish("소개", "secret", client=client)
assert len(calls) == 2
async def test_published_permalink_failure():
def handler(req):
if req.url.path.endswith("/me/threads"):
return httpx.Response(200, json={"id": "11"})
if req.url.path.endswith("/me/threads_publish"):
return httpx.Response(200, json={"id": "22"})
return httpx.Response(500, json={"error": {}})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
result = await threads.publish("소개", "secret", client=client)
assert result == {"id": "22", "permalink": None}
async def test_screen_approval_without_contract_never_queues_post(
client, auth_headers, db_engine
):
h, pid, uid, v = await seed(client, auth_headers, db_engine)
result = await client.post(f"/v1/social/place/{pid}/draft", headers=h, json={})
post_id = uuid.UUID(result.json()["post_id"])
async with db_engine.begin() as c:
await c.execute(
text(
"UPDATE place_social_posts SET body='작성된 원고',status='DRAFT' WHERE post_id=:p"
),
{"p": post_id},
)
response = await client.post(
f"/v1/social/posts/{post_id}/request-approval", headers=h, json={}
)
assert response.status_code == 200, response.text
assert response.json()["post"]["account_bound"] is False
approved = await client.post(
f"/v1/social/posts/{post_id}/decision", headers=h, json={"approve": True}
)
assert approved.json()["applied"] is True
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=10"))
).scalar_one() == 0
async def test_token_cipher_and_oauth_browser_binding(monkeypatch):
from cryptography.fernet import Fernet
from services import social_account_service as accounts
from services.external.social import SocialError
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", "")
assert not accounts.configured()
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
monkeypatch.setenv("THREADS_APP_ID", "test")
monkeypatch.setenv("THREADS_APP_SECRET", "test")
monkeypatch.setenv("THREADS_REDIRECT_URI", "https://example.com/callback")
encrypted = accounts.encrypt("owner-token")
assert (
"owner-token" not in encrypted and accounts.decrypt(encrypted) == "owner-token"
)
url, browser = accounts.begin(uuid.uuid4(), 2)
from urllib.parse import parse_qs, urlparse
state = parse_qs(urlparse(url).query)["state"][0]
with pytest.raises(SocialError, match="INVALID_OAUTH_STATE"):
await accounts.finish(state, "wrong-browser", "unused-code")
@pytest.mark.parametrize("unknown", [False, True])
async def test_post_claim_prevents_second_external_write(
client, auth_headers, db_engine, monkeypatch, unknown
):
from cryptography.fernet import Fernet
from services import social_account_service as accounts
h, pid, uid, v = await seed(client, auth_headers, db_engine)
post_id, _ = await pending(db_engine, pid, uid, v)
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
monkeypatch.setattr(service, "posting_enabled", lambda: True)
async with db_engine.begin() as c:
await c.execute(
text("UPDATE place_social_posts SET status='APPROVED' WHERE post_id=:p"),
{"p": post_id},
)
await c.execute(
text(
"UPDATE owner_social_accounts SET access_token=:t,access_expires_at=now()+interval '1 day' WHERE user_id=:u"
),
{"t": accounts.encrypt("secret"), "u": uid},
)
calls = []
class Adapter:
@staticmethod
def weighted_limit():
return 500
@staticmethod
async def me(*args, **kwargs):
return {"id": "22"}
@staticmethod
async def publish(*args, **kwargs):
calls.append(1)
if unknown:
raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN")
return {"id": "33", "permalink": "https://www.threads.com/@host/post/abc"}
monkeypatch.setattr(service, "adapter", lambda provider: Adapter)
job = {"payload": {"post_id": str(post_id)}}
if unknown:
with pytest.raises(SocialOutcomeUnknown):
await service.run_post(job)
else:
await service.run_post(job)
assert (await service.run_post(job)) == {"skipped": True}
assert calls == [1]
async with db_engine.begin() as c:
assert (
await c.execute(
text("SELECT status FROM place_social_posts WHERE post_id=:p"),
{"p": post_id},
)
).scalar_one() == ("UNKNOWN" if unknown else "POSTED")
async def test_test_post_requires_connected_account(client, auth_headers):
h = await auth_headers("social-test-owner")
res = await client.post("/v1/social/test-post", headers=h, json={"text": "연동 확인"})
assert (
res.status_code == 409 and res.json()["detail"] == "ACCOUNT_CONNECTION_REQUIRED"
)
async def test_test_post_requires_reauth_when_expired(client, auth_headers, db_engine, monkeypatch):
from cryptography.fernet import Fernet
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
h = await auth_headers("social-reauth-owner")
async with db_engine.begin() as c:
uid = (
await c.execute(
text("SELECT user_id FROM users WHERE id=:i"), {"i": "social-reauth-owner"}
)
).scalar_one()
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','needs_reauth')"
),
{"a": uuid.uuid4(), "u": uid},
)
res = await client.post("/v1/social/test-post", headers=h, json={"text": "연동 확인"})
assert (
res.status_code == 409 and res.json()["detail"] == "ACCOUNT_NEEDS_REAUTH"
)
async def test_test_post_publishes_immediately_bypassing_approval(
client, auth_headers, db_engine, monkeypatch
):
"""연동 확인용 게시는 posting_enabled 게이트·승인 절차 없이 바로 나간다."""
from cryptography.fernet import Fernet
from services import social_account_service as accounts
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
monkeypatch.setattr(service, "posting_enabled", lambda: False)
h = await auth_headers("social-verify-owner")
async with db_engine.begin() as c:
uid = (
await c.execute(
text("SELECT user_id FROM users WHERE id=:i"), {"i": "social-verify-owner"}
)
).scalar_one()
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status,access_token,access_expires_at) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked',:t,now()+interval '1 day')"
),
{"a": uuid.uuid4(), "u": uid, "t": accounts.encrypt("secret-token")},
)
calls = []
class Adapter:
@staticmethod
async def publish(text_, token, *, client):
calls.append((text_, token))
return {"id": "99", "permalink": "https://www.threads.com/@host/post/99"}
monkeypatch.setattr(service, "adapter", lambda provider: Adapter)
res = await client.post(
"/v1/social/test-post", headers=h, json={"text": "연동 확인용 테스트"}
)
assert res.status_code == 200, res.text
assert res.json() == {"id": "99", "permalink": "https://www.threads.com/@host/post/99"}
assert calls == [("연동 확인용 테스트", "secret-token")]
async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch):
"""검증: 인가 코드를 받아 계정을 연결하고, 해제까지 한 바퀴 돈다."""
import json as _json
import uuid as _uuid
from cryptography.fernet import Fernet
from sqlalchemy import text as _text
from services import social_account_service as accounts
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
monkeypatch.setenv("THREADS_APP_ID", "app-1")
monkeypatch.setenv("THREADS_APP_SECRET", "secret-1")
monkeypatch.setenv("THREADS_REDIRECT_URI", "https://example.com/v1/social/oauth/callback")
long_lived = "long-lived-token"
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/oauth/access_token"):
return httpx.Response(200, json={"access_token": "short-token", "user_id": "1"})
if path.endswith("/access_token"):
# 장기 토큰 교환.
return httpx.Response(200, json={"access_token": long_lived, "expires_in": 5184000})
if path.endswith("/debug_token"):
return httpx.Response(200, json={"data": {
"is_valid": True, "app_id": "app-1",
"scopes": ["threads_basic", "threads_content_publish"]}})
if path.endswith("/me"):
return httpx.Response(200, json={
"id": "th-1", "username": "mumum", "threads_profile_picture_url": ""})
return httpx.Response(404, json={"error": {"message": "unexpected " + path}})
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
def fake_client(*args, **kwargs):
kwargs["transport"] = transport
return original(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", fake_client)
user_id = _uuid.uuid4()
url, browser = accounts.begin(user_id, 2)
state = parse_qs(urlparse(url).query)["state"][0]
await accounts.finish(state, browser, "auth-code")
async with db_engine.begin() as conn:
row = (await conn.execute(_text(
"SELECT handle, access_token, status, scopes FROM owner_social_accounts "
"WHERE user_id = :u AND deleted = false"), {"u": user_id})).first()
assert row is not None, "연결이 저장되지 않았다"
assert row.handle == "mumum"
assert row.status == "linked"
# 원문이 DB 에 있으면 안 된다.
assert long_lived not in row.access_token
assert accounts.decrypt(row.access_token) == long_lived
scopes = row.scopes if isinstance(row.scopes, list) else _json.loads(row.scopes)
assert set(scopes) == {"threads_basic", "threads_content_publish"}
await accounts.disconnect(user_id, 2)
async with db_engine.begin() as conn:
after = (await conn.execute(_text(
"SELECT status, access_token FROM owner_social_accounts WHERE user_id = :u"),
{"u": user_id})).first()
assert after.status == "revoked" and after.access_token is None, "해제해도 토큰이 남아 있다"
async def test_publish_reused_text_skips_when_no_threads_account(db_engine, auth_headers, client):
from services import social_service as svc
h, pid, uid, v = await seed(client, auth_headers, db_engine)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is None
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM place_social_posts WHERE place_id=:p"), {"p": pid})
).scalar_one() == 0
async def test_publish_reused_text_skips_when_posting_disabled(db_engine, auth_headers, client, monkeypatch):
from services import social_service as svc
monkeypatch.setattr(svc, "posting_enabled", lambda: False)
h, pid, uid, v = await seed(client, auth_headers, db_engine)
async with db_engine.begin() as c:
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": uuid.uuid4(), "u": uid},
)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is None
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM place_social_posts WHERE place_id=:p"), {"p": pid})
).scalar_one() == 0
async def test_publish_reused_text_skips_when_domain_not_fixed(db_engine, auth_headers, client, monkeypatch):
from services import social_service as svc
monkeypatch.setattr(svc, "posting_enabled", lambda: True)
h, pid, uid, v = await seed(client, auth_headers, db_engine)
async with db_engine.begin() as c:
await c.execute(text("UPDATE sites SET domain=NULL WHERE place_id=:p"), {"p": pid})
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": uuid.uuid4(), "u": uid},
)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is None
async def test_publish_reused_text_inserts_approved_post_with_link_and_enqueues_run_post(
db_engine, auth_headers, client, monkeypatch
):
from services import social_service as svc
monkeypatch.setattr(svc, "posting_enabled", lambda: True)
h, pid, uid, v = await seed(client, auth_headers, db_engine)
account_id = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": account_id, "u": uid},
)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is not None
expected_url = svc.site_payload.publish_origin() + "/s/social-stay"
async with db_engine.begin() as c:
row = (
await c.execute(
text(
"SELECT status, body, link_url, decided_via, account_id FROM place_social_posts WHERE post_id=:p"
),
{"p": result},
)
).first()
assert row.status == "APPROVED"
assert row.body == f"오늘도 마당이 조용합니다.\n\n{expected_url}"
assert row.link_url == expected_url
assert row.decided_via == "mini_blog"
assert row.account_id == account_id
job_count = (
await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=10"))
).scalar_one()
assert job_count == 1
# 같은 site_version 에 두 번째 호출 — 유니크 인덱스가 중복 삽입을 막는다
second = await svc.publish_reused_text(uid, pid, "다른 문구")
assert second is None
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM place_social_posts WHERE place_id=:p"), {"p": pid})
).scalar_one() == 1
# ── 캡션에 지역·업종·상호 (2026-09-30) ──────────────────────────────────
def test_캡션_업종말은_홈페이지_본문_말과_다르다():
"""★ 쓰이는 자리가 다르다 — "숙박업소에서 쉬어 가세요" 는 사람이 쓰는 말이 아니다."""
from common.enums import PlaceCategory
from services.prompts import copy as copy_prompt, social as social_prompt
assert social_prompt.caption_category(PlaceCategory.LODGING.value) == "숙소"
assert copy_prompt._CATEGORY_LABEL[PlaceCategory.LODGING] == "숙박업소"
# 모르는 값이면 그 말만 빠진다 — 캡션 생성이 통째로 막히지 않는다.
assert social_prompt.caption_category(999) == ""
assert social_prompt.caption_category(None) == ""
def test_캡션_지역말은_행정표기를_줄인다():
"""★ region_label 은 쇼케이스 목록용이라 "전북특별자치도 군산시" 가 맞다. 캡션은 다르다 —
사람이 검색하는 말은 "군산 숙소" 이고 AI 가 인용할 때 잡는 말도 그쪽이다."""
from services.prompts import social as social_prompt
assert social_prompt.caption_region("전북특별자치도 군산시") == "전북 군산"
assert social_prompt.caption_region("서울특별시 종로구") == "서울 종로"
assert social_prompt.caption_region("제주특별자치도 제주시") == "제주" # 겹치면 한 번만
assert social_prompt.caption_region("") == ""
# ★ 표에 없는 시·도는 그대로 둔다(시·군·구 접미사만 뗀다).
assert social_prompt.caption_region("어느도 어디시") == "어느도 어디"
def test_프롬프트가_지역_업종_상호를_문장으로_넣게_시킨다():
"""★ 해시태그가 아니다. AI 는 태그가 아니라 문장을 읽고, 이 글은 사장님 명의다."""
from services.prompts import social as social_prompt
prompt = social_prompt.build_prompt(
"스테이,머뭄", [], 400, region="전북 군산", category="숙소")
assert "전북 군산의 숙소 스테이,머뭄" in prompt
assert "태그로 나열하지 말고" in prompt
assert "해시태그" in prompt # 기존 금지는 그대로 남는다
# 값이 없으면 그 말만 빠진다.
plain = social_prompt.build_prompt("스테이,머뭄", [], 400)
assert '"스테이,머뭄" 를 자연스럽게' in plain
async def test_지역과_업종은_근거에_얹힌다(monkeypatch):
"""★ 얹지 않으면 본문에 쓴 순간 '근거 없는 주장' 으로 반려되고 3회 재시도를 태우고 실패한다."""
from types import SimpleNamespace
from services.external import gemini_text
from services.llm import provider as llm_provider
seen = {}
def fake_ground_check(body, grounds):
seen["keys"] = {g.key for g in grounds}
return True, []
async def fake_generate(client, model, **kw):
return SimpleNamespace(
json={"body": "전북 군산의 숙소 스테이,머뭄에서 조용한 하루를 보내세요.",
"fact_keys": ["check_in_time"]},
text="", usage=None,
)
monkeypatch.setattr(gemini_text, "ground_check", fake_ground_check)
active = llm_provider.active()
monkeypatch.setattr(active, "is_configured", lambda: True)
monkeypatch.setattr(active, "generate", fake_generate)
facts = [gemini_text.FactInput(key="check_in_time", label="체크인", value="15:00")]
await gemini_text.generate_social_post(
"스테이,머뭄", facts, "https://example.com/s/x", 2, region="전북 군산", category="숙소")
assert {"name", "region", "category", "check_in_time"} <= seen["keys"]