발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.
★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
행위를 한다. 아래 결정이 전부 여기서 나왔다.
승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.
★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.
플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.
- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔
밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
분 단위 정밀도가 필요 없다
검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
Python
347 lines
13 KiB
Python
import json
|
|
import uuid
|
|
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=8"))
|
|
).scalar_one() == 1
|
|
|
|
|
|
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=9"))
|
|
).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):
|
|
async def forbidden(*args, **kwargs):
|
|
raise AssertionError("paid call")
|
|
|
|
monkeypatch.setattr(gemini_text, "call", 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):
|
|
monkeypatch.setattr(gemini_text, "is_configured", lambda: True)
|
|
bodies = iter(["가" * 501, "체크인은 15:00입니다."])
|
|
|
|
async def call(*args, **kwargs):
|
|
return {
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"text": json.dumps(
|
|
{
|
|
"body": next(bodies),
|
|
"fact_keys": ["check_in_time"],
|
|
}
|
|
)
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(gemini_text, "call", call)
|
|
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=9"))
|
|
).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")
|