[feat] solution/backend: 카톡이 연결된 사장님에게는 승인 알림을 카톡으로만 — 카톡이 실패했을 때만 메일로 대체

같은 알림이 메일과 카톡으로 두 통 가는 게 번거로워 카톡이 연결돼 있으면 카톡만 보낸다.
채널 친구 아님·차단·Event API 장애로 카톡이 안 나가면 메일로 받치고, 둘 다 못 보내면
SENT 로 표시하지 않아 다음 스윕이 다시 시도한다.
This commit is contained in:
김성경 2026-09-30 10:42:03 +09:00
parent e0155d0423
commit 9d98f2f40b
6 changed files with 113 additions and 48 deletions

View File

@ -100,7 +100,8 @@ KAKAO_BOT_ID=
KAKAO_BOT_REST_API_KEY= KAKAO_BOT_REST_API_KEY=
# 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0. # 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0.
KAKAO_EVENT_DEV=0 KAKAO_EVENT_DEV=0
# 미니블로그 승인 알림을 메일에 더해 연결된 카톡으로도 보낸다(Event API). # 카톡이 연결된 사장님에게는 미니블로그 승인 알림을 메일 대신 카톡으로 보낸다(Event API).
# 카톡이 실패했을 때만 메일로 대체하고, 연결이 없으면 예전처럼 메일이다.
# ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면 # ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면
# 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐. # 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐.
KAKAO_APPROVAL_PUSH_ENABLED=0 KAKAO_APPROVAL_PUSH_ENABLED=0

View File

@ -359,7 +359,7 @@ https://<발행호스트>/v1/agent/kakao/webhook
| | 결정 | | | 결정 |
|---|---| |---|---|
| 발송 | **카톡과 메일 둘 다.** 카톡이 연결돼 있어도 메일을 같이 보낸다 — 카톡 발송은 채널 친구가 아니거나 차단했으면 실패하므로 메일이 누락을 막는다 | | 발송 | **카톡이 연결돼 있으면 카톡으로만 보내고, 카톡이 실패했을 때만 메일로 대체한다**(2026-09-30 변경 — 처음엔 '카톡과 메일 둘 다'였는데 같은 알림이 두 통 가는 게 번거롭다는 지시. 카톡만 보내면 채널 친구 아님·차단·Event API 장애인 날 알림이 통째로 사라져 실패 때만 메일로 받쳤다). 연결이 없거나 스위치가 꺼져 있으면 예전처럼 메일. 카톡도 메일도 못 보내면 SENT 로 표시하지 않아 다음 스윕이 다시 시도하고 '승인 알림보내기' 는 실패로 답한다 |
| 승인 방식 | 메시지의 **[승인] [수정] 인라인 버튼** + 누른 사람이 **연결된 본인인지·그 글이 본인 가게 것인지 서버가 확인**. 링크가 없어 메신저 미리보기가 먼저 열어 승인되는 문제가 처음부터 없다. 승인은 기존 `PostService.approve_by_owner` 를 그대로 탄다 | | 승인 방식 | 메시지의 **[승인] [수정] 인라인 버튼** + 누른 사람이 **연결된 본인인지·그 글이 본인 가게 것인지 서버가 확인**. 링크가 없어 메신저 미리보기가 먼저 열어 승인되는 문제가 처음부터 없다. 승인은 기존 `PostService.approve_by_owner` 를 그대로 탄다 |
| 진행 | **1단계(이 절)**: 클라이언트 + 테스트 발송으로 규격 확인. 2단계: 버튼 응답·승인 처리·발송 연결 | | 진행 | **1단계(이 절)**: 클라이언트 + 테스트 발송으로 규격 확인. 2단계: 버튼 응답·승인 처리·발송 연결 |
@ -417,7 +417,7 @@ cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key <
## 2-1 진행 — 발송과 메시지 그리기 (2026-09-29) ## 2-1 진행 — 발송과 메시지 그리기 (2026-09-29)
``` ```
blog_jobs._send_one 메일 + 카톡 Event API(params: post_id, edit_token) — 하나라도 나가면 SENT blog_jobs._send_one 카톡 Event API(params: post_id, edit_token) — 실패했을 때만 메일로 대체, 나가면 SENT
오픈빌더 이벤트 블록 스킬 데이터 응답 → 우리 웹훅(POST /v1/agent/kakao/webhook) 오픈빌더 이벤트 블록 스킬 데이터 응답 → 우리 웹훅(POST /v1/agent/kakao/webhook)
kakao_bot._handle userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리 kakao_bot._handle userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리
channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수정하기] 링크 channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수정하기] 링크
@ -430,8 +430,8 @@ channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수
- [수정하기] 는 메일의 '고쳐서 올리려면' 과 **같은 일회용 코드**다(`/v1/site/post/edit?t=`). - [수정하기] 는 메일의 '고쳐서 올리려면' 과 **같은 일회용 코드**다(`/v1/site/post/edit?t=`).
평문은 발송 시점에만 알아서 Event API `params.edit_token` 으로 넘긴다 — 어느 쪽이든 먼저 평문은 발송 시점에만 알아서 Event API `params.edit_token` 으로 넘긴다 — 어느 쪽이든 먼저
누른 쪽이 쓴다. 코드는 카카오 서버를 지나가므로 로그에는 params 의 **키만** 남기고 값은 남기지 않는다. 누른 쪽이 쓴다. 코드는 카카오 서버를 지나가므로 로그에는 params 의 **키만** 남기고 값은 남기지 않는다.
- 메일이 나갔으면 카톡보다 먼저 SENT 로 표시한다(웹훅이 곧바로 글을 읽는다). 카톡만 나가는 - 카톡 발송이 성공한 **뒤에** SENT 로 표시한다(카톡이 실패하면 메일 발송 뒤). 그 사이에 웹훅이 이 글을 읽어도 되는 이유는 웹훅이
경우는 발송 성공 뒤에 표시하고, 웹훅은 REVIEWED 글도 읽는다. REVIEWED 글도 읽고, 사장님이 버튼을 누르기까지의 시간이 그 간격보다 훨씬 길어서다.
- 링크 버튼은 `textCard` 로 본문(`simpleText`)과 따로 둔다 — 카드 설명 길이 제한에 글 문구가 걸리지 않게. - 링크 버튼은 `textCard` 로 본문(`simpleText`)과 따로 둔다 — 카드 설명 길이 제한에 글 문구가 걸리지 않게.
### 콘솔에서 바꿔야 하는 것 ### 콘솔에서 바꿔야 하는 것

View File

@ -122,11 +122,13 @@
수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id` 수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id`
까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가 까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가
`place_id` 불일치를 걸러낸다) `place_id` 불일치를 걸러낸다)
- **카톡 병행**(2026-09-29 결정: 카톡과 메일 **둘 다**): 사장님이 카카오톡을 연결했고 - **카톡 알림**(2026-09-30 변경: 연결돼 있으면 **카톡으로만, 실패했을 때만 메일로 대체**): 사장님이
`KAKAO_APPROVAL_PUSH_ENABLED=1` 이면 메일에 더해 Event API 로도 보낸다 카카오톡을 연결했고 `KAKAO_APPROVAL_PUSH_ENABLED=1` 이면 메일 대신 Event API 로 보낸다
(`blog_jobs._push_kakao`, 규격·콘솔 준비는 `docs/AGENT.md` 5단계). 카톡은 채널 친구가 (`blog_jobs._push_kakao`, 규격·콘솔 준비는 `docs/AGENT.md` 5단계). 처음(2026-09-29)엔 '둘 다'
아니거나 차단했으면 실패하므로 메일을 빼지 않는다. **하나라도 나갔으면 SENT**, 아무 데도 였는데 같은 알림이 두 통 가는 게 번거롭다는 지시로 바꿨다. 카톡만 보내면 채널 친구 아님·차단·
안 나갔으면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 글 본문과 [수정하기] 링크는 Event API 장애인 날 알림이 통째로 사라져서 **카톡이 실패했을 때만 메일로 대체**한다. 연결이
없거나 스위치가 꺼져 있으면 예전처럼 메일이다. 카톡도 메일도 못 보내면 SENT 로 표시하지 않아
다음 스윕이 다시 시도하고 '승인 알림보내기' 는 실패로 답한다. 글 본문과 [수정하기] 링크는
오픈빌더 이벤트 블록의 스킬(우리 웹훅)이 그린다 — params 로 글 ID 와 수정용 일회용 코드가 오픈빌더 이벤트 블록의 스킬(우리 웹훅)이 그린다 — params 로 글 ID 와 수정용 일회용 코드가
가고, 웹훅이 **연결된 본인 가게의 글인지 다시 확인한 뒤에만** 본문을 준다 가고, 웹훅이 **연결된 본인 가게의 글인지 다시 확인한 뒤에만** 본문을 준다
(`channel.approval_notice`). (`channel.approval_notice`).

View File

@ -49,7 +49,8 @@ class AgentConfig(BaseSettings):
# "1" 이면 봇 ID 뒤에 "!" 를 붙여 **개발 채널**로 보낸다(운영 채널과 요청 주소가 다르다). # "1" 이면 봇 ID 뒤에 "!" 를 붙여 **개발 채널**로 보낸다(운영 채널과 요청 주소가 다르다).
# ★ KAKAO_BOT_ID 자체를 고쳐 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다. # ★ KAKAO_BOT_ID 자체를 고쳐 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다.
KAKAO_EVENT_DEV: str = "0" KAKAO_EVENT_DEV: str = "0"
# 미니블로그 승인 알림을 메일에 더해 카톡으로도 보낸다. ★ 기본 꺼짐 — 오픈빌더에 이벤트 # 카톡이 연결된 사장님에게는 미니블로그 승인 알림을 메일 대신 카톡으로 보낸다(카톡이 실패했을
# 때만 메일로 대체, 연결이 없으면 메일). ★ 기본 꺼짐 — 오픈빌더에 이벤트
# 블록(스킬 연결)과 배포가 끝나기 전에 켜면 사장님 카톡에 빈 말풍선이 간다. # 블록(스킬 연결)과 배포가 끝나기 전에 켜면 사장님 카톡에 빈 말풍선이 간다.
KAKAO_APPROVAL_PUSH_ENABLED: str = "0" KAKAO_APPROVAL_PUSH_ENABLED: str = "0"
# 오픈빌더에 정의한 이벤트 이름. 말풍선을 그리는 블록이 이 이름에 걸려 있다. # 오픈빌더에 정의한 이벤트 이름. 말풍선을 그리는 블록이 이 이름에 걸려 있다.

View File

@ -267,45 +267,46 @@ async def _push_kakao(channel_user_key: str, post, edit_token: str) -> bool:
async def _send_one(place, user, post) -> bool: async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 메일·카톡 발송 → 하나라도 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 """토큰 발급 → 발송 → 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 DB 를 안 건드린다
DB 를 안 건드린다(다시 시도할 길을 막지 않는다). (다시 시도할 길을 막지 않는다).
★ 카톡과 메일은 **둘 다** 보낸다(2026-09-29 결정). 카톡은 채널 친구가 아니거나 차단했으면 ★ 카톡이 연결돼 있으면 **카톡으로만** 보내고, 카톡이 **실패했을 때만** 메일로 대체한다
실패하므로, 카톡이 붙어도 메일을 빼지 않는다.""" (2026-09-30). 처음(2026-09-29)엔 '둘 다'였는데 같은 알림이 두 통 가는 게 번거롭다는 지시로
바꿨고, 카톡만 보내면 채널 친구가 아니거나 차단했거나 Event API 가 죽은 날 알림이 통째로
사라져서 실패 때만 메일로 받치게 했다. 연결이 없거나 스위치가 꺼져 있으면 예전처럼 메일이다.
★ 카톡도 메일도 못 보내면 SENT 로 표시하지 않는다 — 다음 스윕이 다시 시도하고 '승인 알림보내기'
는 실패로 답한다."""
token, token_hash, expires = blog_service.issue_token() token, token_hash, expires = blog_service.issue_token()
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고, # ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023). # 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
edit_token, edit_hash, _edit_expires = blog_service.issue_token() edit_token, edit_hash, _edit_expires = blog_service.issue_token()
mail_ok = False
if _can_mail(place, user):
body = _mail_body(
place_name=place.name, post=post, user=user,
origin=_app_origin(), approve_token=token, edit_token=edit_token,
)
mail_ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
async def mark_sent(): async def mark_sent():
await DB_SESSION_MNG.execute_lambda_run( await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [place_posts.DBType()],
[lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)], [lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
) )
# ★ 메일이 나갔으면 카톡보다 **먼저** SENT 로 표시한다. 이벤트가 나가자마자 웹훅이 이 글을
# 읽는데, 그때 수정 코드 해시가 저장돼 있으면 [수정하기] 가 바로 먹는다. 카톡만 나가는
# 경우는 발송이 성공해야 표시할 수 있어 그 뒤에 한다 — 사장님이 버튼을 누르기까지의
# 시간이 그 간격보다 훨씬 길다(웹훅은 REVIEWED 글도 읽는다).
if mail_ok:
await mark_sent()
kakao_key = await _kakao_target(user) kakao_key = await _kakao_target(user)
if kakao_key is None: if kakao_key is not None:
return mail_ok # 카톡 발송이 성공해야 SENT 로 표시할 수 있어 그 뒤에 한다. 이벤트가 나가자마자 웹훅이 이
# 글을 읽어도 되는 이유: 웹훅은 REVIEWED 글도 읽고, 사장님이 버튼을 누르기까지의 시간이
# 이 간격보다 훨씬 길다.
if await _push_kakao(kakao_key, post, edit_token):
await mark_sent()
return True
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림이 안 나가 메일로 대체한다")
kakao_ok = await _push_kakao(kakao_key, post, edit_token) if not _can_mail(place, user):
if kakao_ok and not mail_ok: return False
await mark_sent() body = _mail_body(
return mail_ok or kakao_ok place_name=place.name, post=post, user=user,
origin=_app_origin(), approve_token=token, edit_token=edit_token,
)
if not mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body):
return False
await mark_sent()
return True
async def send_reviewed() -> int: async def send_reviewed() -> int:
@ -327,7 +328,8 @@ async def send_reviewed() -> int:
if not target: if not target:
continue continue
place, user = target place, user = target
# 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 하나라도 되면 _send_one 이 갈래를 정한다. # 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 어느 쪽으로 보낼지는 _send_one 이 정한다
# (카톡이 연결돼 있으면 카톡, 실패했을 때만 메일 — 연결이 없으면 메일).
if not _can_mail(place, user) and await _kakao_target(user) is None: if not _can_mail(place, user) and await _kakao_target(user) is None:
continue continue
if await _send_one(place, user, post): if await _send_one(place, user, post):

View File

@ -459,8 +459,8 @@ async def test_send_now_cannot_be_triggered_for_someone_elses_place(client, db_e
assert res.json()["result"]["success"] is False assert res.json()["result"]["success"] is False
async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, login_id, name): async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, login_id, name, link=True):
"""발행된 사이트 + 메일 주소 + 카톡이 연결된 사장님. 카톡 Event API 는 켜 둔다.""" """발행된 사이트 + 메일 주소 + (link=True 면) 카톡이 연결된 사장님. 카톡 Event API 는 켜 둔다."""
from services import kakao_link_service from services import kakao_link_service
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh") monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
@ -477,7 +477,8 @@ async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypa
) )
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": f"{login_id}@example.com", "id": login_id}) await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": f"{login_id}@example.com", "id": login_id})
user_id = (await conn.execute(text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id})).scalar_one() user_id = (await conn.execute(text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id})).scalar_one()
await kakao_link_service.redeem((await kakao_link_service.issue_code(user_id))["code"], f"speaker-{login_id}") if link:
await kakao_link_service.redeem((await kakao_link_service.issue_code(user_id))["code"], f"speaker-{login_id}")
return place_id, f"speaker-{login_id}", h return place_id, f"speaker-{login_id}", h
@ -496,8 +497,9 @@ def _record_kakao_send(monkeypatch, *, fail=False):
return calls return calls
async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine, auth_headers, monkeypatch): async def test_send_reviewed_sends_only_kakao_when_linked(client, db_engine, auth_headers, monkeypatch):
"""승인 알림은 메일에 더해 연결된 카카오톡으로도 나간다(2026-09-29, 결정: 카톡과 메일 둘 다). """카톡이 연결돼 있으면 승인 알림은 카톡으로만 나가고 메일은 안 나간다(2026-09-30 변경 —
처음엔 '둘 다'였는데 사장님에게 같은 알림이 두 통 가는 게 번거롭다고 했다).
카톡에는 글 ID 와 수정 링크용 일회용 코드가 params 로 실려, 웹훅이 메시지를 그린다.""" 카톡에는 글 ID 와 수정 링크용 일회용 코드가 params 로 실려, 웹훅이 메시지를 그린다."""
from services import blog_jobs, mail_service from services import blog_jobs, mail_service
@ -511,7 +513,7 @@ async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine,
sent = await blog_jobs.send_reviewed() sent = await blog_jobs.send_reviewed()
assert sent == 1 assert sent == 1
assert len(mails) == 1 assert mails == []
assert len(kakao) == 1 assert len(kakao) == 1
assert kakao[0]["key"] == speaker assert kakao[0]["key"] == speaker
assert kakao[0]["event"] == "post_approval" assert kakao[0]["event"] == "post_approval"
@ -520,8 +522,9 @@ async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine,
assert await _status(db_engine, post_id) == PostStatus.SENT.value assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_a_failed_kakao_push_does_not_block_the_mail(client, db_engine, auth_headers, monkeypatch): async def test_a_failed_kakao_push_falls_back_to_mail(client, db_engine, auth_headers, monkeypatch):
"""카톡은 채널 친구가 아니거나 차단했으면 실패한다 — 그래서 메일도 같이 보낸다.""" """카톡은 채널 친구가 아니거나 차단했거나 Event API 가 죽으면 실패한다 — 그때만 메일로 대체한다
(2026-09-30: 연결돼 있으면 카톡만, 실패했을 때만 메일). 알림이 통째로 사라지지 않게."""
from services import blog_jobs, mail_service from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True) monkeypatch.setattr(mail_service, "is_configured", lambda: True)
@ -587,12 +590,13 @@ async def test_kakao_push_is_off_unless_switched_on(client, db_engine, auth_head
assert kakao == [] assert kakao == []
async def test_send_now_also_pushes_to_kakao(client, db_engine, auth_headers, monkeypatch): async def test_send_now_sends_only_kakao_when_linked(client, db_engine, auth_headers, monkeypatch):
"""'승인 알림보내기' 버튼도 같은 경로다 — 메일만 가고 카톡은 빠지면 안 된다.""" """'승인 알림보내기' 버튼도 같은 경로다 — 연결돼 있으면 카톡으로만 간다."""
from services import mail_service from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True) monkeypatch.setattr(mail_service, "is_configured", lambda: True)
monkeypatch.setattr(mail_service, "send", lambda **kwargs: True) mails = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True)
kakao = _record_kakao_send(monkeypatch) kakao = _record_kakao_send(monkeypatch)
place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend6", "카톡즉시펜션") place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend6", "카톡즉시펜션")
await _seed_post(db_engine, place_id, scheduled=date.today()) await _seed_post(db_engine, place_id, scheduled=date.today())
@ -601,6 +605,61 @@ async def test_send_now_also_pushes_to_kakao(client, db_engine, auth_headers, mo
assert res.json()["result"]["success"] is True assert res.json()["result"]["success"] is True
assert len(kakao) == 1 assert len(kakao) == 1
assert mails == []
async def test_send_now_falls_back_to_mail_when_kakao_fails(client, db_engine, auth_headers, monkeypatch):
"""'승인 알림보내기' 도 같은 정책이다 — 카톡이 실패하면 메일로 나가 사장님 화면에는 성공으로 보인다."""
from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
mails = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True)
kakao = _record_kakao_send(monkeypatch, fail=True)
place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend7", "카톡즉시실패펜션")
await _seed_post(db_engine, place_id, scheduled=date.today())
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
assert res.json()["result"]["success"] is True
assert len(kakao) == 1
assert len(mails) == 1
async def test_send_now_reports_failure_when_both_kakao_and_mail_fail(client, db_engine, auth_headers, monkeypatch):
"""둘 다 안 나갔으면 사장님 화면에 실패로 보여야 한다 — 성공처럼 보이면 알림이 안 간 걸 모른다."""
from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: False)
_record_kakao_send(monkeypatch, fail=True)
place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend9", "카톡메일둘다실패펜션")
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
assert res.json()["result"]["success"] is False
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_mail_is_still_sent_when_kakao_is_not_linked(client, db_engine, auth_headers, monkeypatch):
"""카톡을 연결하지 않은 사장님은 스위치가 켜져 있어도 예전처럼 메일을 받는다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
mails = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True)
kakao = _record_kakao_send(monkeypatch)
place_id, _speaker, _h = await _publish_site_and_link_kakao(
client, db_engine, auth_headers, monkeypatch, "kakaosend8", "카톡미연결펜션", link=False,
)
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert len(mails) == 1
assert kakao == []
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def _day_pass_headers(db_engine, login_id: str) -> dict: async def _day_pass_headers(db_engine, login_id: str) -> dict: