Record A3 E2E results and add API-level e2e script

scripts/e2e_a3.py exercises the messenger checklist against a live
core-backend (16/16). Mark A3 E2E done; keep Android UI steps in README.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-30 05:05:53 +00:00
parent dcf6a006f9
commit 7dc4216dad
No known key found for this signature in database
2 changed files with 211 additions and 1 deletions

View File

@ -29,7 +29,20 @@ flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080 flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080
``` ```
## E2E 수동 QA 체크 (에뮬레이터/실기기) ## E2E
**API 레벨 (클라우드/CI에서 실행 가능)**
```bash
# core-backend :8080 + ai-service :8001 기동 후
export ADMIN_API_TOKEN=dev-admin-token
python3 scripts/e2e_a3.py
```
A3 체크리스트(가입·연락처·대화·히스토리·draft/L1·에스컬레이션·되돌리기·거부권·화이트리스트)를
HTTP로 검증한다.
**에뮬레이터/실기기 UI 탭 (Android SDK 필요)**
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에) 1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
2. 연락처에 상대 사용자 ID 등록 → 대화 시작 2. 연락처에 상대 사용자 ID 등록 → 대화 시작

197
scripts/e2e_a3.py Executable file
View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""A3 API-level E2E against a running core-backend (+ ai-service for draft/escalate).
Covers mobile/README.md checklist at the HTTP layer.
Android emulator/device UI taps are out of scope in this cloud VM (no Android SDK).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
BASE = os.environ.get("CORE_API_BASE", "http://127.0.0.1:8080")
ADMIN = os.environ.get("ADMIN_API_TOKEN", "dev-admin-token")
passed = 0
failed = 0
def ok(msg: str) -> None:
global passed
passed += 1
print(f" OK: {msg}")
def fail(msg: str) -> None:
global failed
failed += 1
print(f" FAIL: {msg}")
def step(msg: str) -> None:
print(f"\n==> {msg}")
def req(method: str, path: str, body=None, token: str | None = None, admin: bool = False):
data = None if body is None else json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if admin:
headers["Authorization"] = f"Bearer {ADMIN}"
elif token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request) as resp:
raw = resp.read().decode()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError:
payload = {"raw": raw}
return e.code, payload
def main() -> int:
step("health")
code, body = req("GET", "/health")
ok("core health") if code == 200 else fail(f"health {code} {body}")
step("1) invite + signup (two users)")
_, ia = req("POST", "/invites", {}, admin=True)
_, ib = req("POST", "/invites", {}, admin=True)
code_a, sa = req("POST", "/auth/signup", {"invite_code": ia["code"], "display_name": "E2E Alice"})
code_b, sb = req("POST", "/auth/signup", {"invite_code": ib["code"], "display_name": "E2E Bob"})
if code_a == 200 and code_b == 200 and sa.get("token") and sb.get("token"):
ok(f"signup tokens for #{sa['id']} and #{sb['id']}")
else:
fail(f"signup a={code_a} b={code_b}")
return 1
token_a, token_b = sa["token"], sb["token"]
id_a, id_b = sa["id"], sb["id"]
step("2) contact + conversation create")
_, contact = req(
"POST",
f"/users/{id_a}/contacts",
{"display_name": "Bob", "contact_user_id": id_b, "relationship_note": "e2e"},
token=token_a,
)
contact_id = contact["id"]
_, conv = req(
"POST",
"/conversations",
{"user_ids": [id_a, id_b], "contact_id": contact_id},
token=token_a,
)
conv_id = conv["id"]
ok(f"conversation #{conv_id} via contact #{contact_id}")
_, clist = req("GET", "/conversations", token=token_a)
ids = [c["id"] for c in clist.get("conversations", [])]
ok("conversation list includes room") if conv_id in ids else fail(f"list missing room: {ids}")
step("3) human message + history")
_, msg = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": "안녕 Bob", "sender_mode": "human"},
token=token_a,
)
msg_id = msg["id"]
_, hist = req("GET", f"/conversations/{conv_id}/messages", token=token_a)
hist_ids = [m["id"] for m in hist.get("messages", [])]
ok(f"history retains message #{msg_id}") if msg_id in hist_ids else fail("history")
step("4) draft + L1 approved twin send")
dcode, draft = req(
"POST",
f"/conversations/{conv_id}/draft",
{"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅇ 알겠음", "ㅋㅋ 그래"]},
token=token_a,
)
if dcode == 200 and draft.get("status") in {"ok", "no_key", "escalate"}:
ok(f"draft status={draft.get('status')}")
else:
fail(f"draft {dcode} {draft}")
req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L1"}, token=token_a)
twin_text = "ㅇㅇ 알겠음 나중에"
if draft.get("status") == "ok" and draft.get("text"):
twin_text = draft["text"]
tcode, twin = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": twin_text, "sender_mode": "twin", "approved": True},
token=token_a,
)
twin_id = twin.get("id")
if tcode == 200 and twin_id:
ok(f"L1 approved twin message #{twin_id}")
else:
fail(f"twin send {tcode} {twin}")
return 1
step("5) escalation path + inbox logs")
ecode, _ = req(
"POST",
f"/conversations/{conv_id}/messages",
{
"sender_id": id_a,
"text": "계좌로 돈 보내줄게 계좌번호 알려줘",
"sender_mode": "twin",
"approved": True,
},
token=token_a,
)
if ecode != 200:
ok(f"escalating twin blocked (HTTP {ecode})")
else:
fail("escalating twin unexpectedly accepted")
_, logs = req("GET", f"/users/{id_a}/escalation-logs", token=token_a)
if "escalation_logs" in logs:
ok(f"escalation-logs count={len(logs['escalation_logs'])}")
else:
fail("escalation-logs shape")
step("6) retract twin + veto")
rcode, _ = req("POST", f"/messages/{twin_id}/retract", token=token_a)
ok(f"retract HTTP {rcode}") if rcode == 200 else fail(f"retract {rcode}")
vcode, _ = req("POST", f"/conversations/{conv_id}/veto", token=token_b)
ok(f"peer veto HTTP {vcode}") if vcode == 200 else fail(f"veto {vcode}")
bcode, _ = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": "veto 이후", "sender_mode": "twin", "approved": True},
token=token_a,
)
ok(f"twin blocked after veto (HTTP {bcode})") if bcode != 200 else fail("twin allowed after veto")
step("7) whitelist CRUD + autonomy")
_, wl = req(
"POST",
f"/users/{id_a}/whitelist-rules",
{"topic_keyword": "날씨"},
token=token_a,
)
_, wlist = req("GET", f"/users/{id_a}/whitelist-rules", token=token_a)
rules = wlist.get("whitelist_rules", [])
ok("whitelist add/list") if any(r.get("topic_keyword") == "날씨" for r in rules) else fail("whitelist")
dcode, _ = req("DELETE", f"/users/{id_a}/whitelist-rules/{wl['id']}", token=token_a)
ok("whitelist delete") if dcode == 200 else fail(f"whitelist delete {dcode}")
acode, _ = req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L0"}, token=token_a)
ok("autonomy L0 reset") if acode == 200 else fail(f"autonomy {acode}")
step("8) contacts list")
_, contacts = req("GET", f"/users/{id_a}/contacts", token=token_a)
cids = [c["id"] for c in contacts.get("contacts", [])]
ok("contacts list") if contact_id in cids else fail("contacts list")
print(f"\n==== E2E RESULT: {passed} passed, {failed} failed ====")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())