test+chore: 테스트 인프라·E2E 스펙·개발 문서 갱신

- 테스트 헬퍼(_factories·_fake_llm)·conftest 정비, phase18 고급기능 테스트 추가,
  federation/multiuser/risk/nl_parser/export 갱신, 폐기 시드 테스트 제거
- playwright E2E 스펙 전반 갱신, dev/phase-16-real-mail 문서 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
I Luk Kim 2 months ago
parent 10fcaec080
commit 6fe8a74e59

@ -0,0 +1,100 @@
# backend/tests/_factories.py — 테스트용 최소 데이터 빌더 (phase-16+: 3개 도메인 시드 제거 후)
# 시드가 더 이상 메일/일정/작업 데이터를 깔지 않으므로, 기능 테스트는 필요한 데이터를 직접 만든다.
from app.models import (
CalEvent,
ConnectorAccount,
ConnectorDomain,
ConnectorMode,
ConnState,
Email,
MailAccount,
Meeting,
MeetingAction,
Prio,
Task,
TaskComment,
TaskStatus,
)
def make_task(s, *, id, title="테스트 작업", project_id="me", parent_id=None,
status="todo", assignee_id="jiwoo", prio="보통", **kw):
t = Task(
id=id, title=title, project_id=project_id, parent_id=parent_id,
status=TaskStatus(status), assignee_id=assignee_id, prio=Prio(prio), **kw,
)
s.add(t)
s.commit()
s.refresh(t)
return t
def make_comment(s, *, id, task_id, person_id="minseo", text="확인했습니다"):
c = TaskComment(id=id, task_id=task_id, person_id=person_id, text=text)
s.add(c)
s.commit()
return c
def make_mail_account(s, *, id="work", name="회사", email="jiwoo@gmail.com",
tone="blue", kind="Google", connector="gmail", sort_order=0):
a = MailAccount(id=id, name=name, email=email, tone=tone, kind=kind,
connector=connector, sort_order=sort_order)
s.add(a)
s.commit()
return a
def make_email(s, *, id, account="work", from_key="hyunwoo", to="", subject="테스트 메일",
time="방금", date="오늘", read=False, starred=False, has_attach=False,
labels=None, preview="", body=None, ai_json=None, sort_order=0,
received_at=None):
e = Email(
id=id, account=account, from_key=from_key, to=to, subject=subject, time=time,
date=date, read=read, starred=starred, has_attach=has_attach,
labels=labels or [], preview=preview, body=body or [], ai_json=ai_json or {},
sort_order=sort_order, received_at=received_at,
)
s.add(e)
s.commit()
s.refresh(e)
return e
def make_cal_event(s, *, id, day=8, start="10:00", end="11:00", title="테스트 일정",
cal="work", loc="", note="", soon=False, people="", sort_order=0):
ev = CalEvent(id=id, day=day, start=start, end=end, title=title, cal=cal,
loc=loc, note=note, soon=soon, people=people, sort_order=sort_order)
s.add(ev)
s.commit()
s.refresh(ev)
return ev
def make_meeting(s, *, event_id, phase="upcoming", one_on_one=False):
m = Meeting(id=event_id, event_id=event_id, phase=phase, one_on_one=one_on_one)
s.add(m)
s.commit()
return m
def make_meeting_action(s, *, id, meeting_id=None, event_id=None, idx=0, who="",
text="액션", when_text="이번 주", source="meeting", added=False):
a = MeetingAction(id=id, meeting_id=meeting_id, event_id=event_id, idx=idx, who=who,
text=text, when_text=when_text, source=source, added=added)
s.add(a)
s.commit()
return a
def make_connected_mail_account(s, *, id="ca-mail-gmail-test", provider="gmail",
email="jiwoo@gmail.com"):
"""발송 테스트용: real+connected 메일 ConnectorAccount."""
a = ConnectorAccount(
id=id, user_id="jiwoo", domain=ConnectorDomain.mail, provider=provider,
mode=ConnectorMode.real, state=ConnState.connected,
external_account_id=email, name=email, kind="Google",
)
s.add(a)
s.commit()
return a

@ -0,0 +1,124 @@
# backend/tests/_fake_llm.py — 테스트 전용 결정적 LLM (구 HeuristicProvider 로직 이전, phase-16+)
# 프로덕션에서 휴리스틱 폴백을 제거했으므로, 테스트 결정성은 이 FakeLLM 을 get_provider 에
# 주입(conftest)해 확보한다. 실 Ollama 의존 없이 분류/분석/회신을 규칙으로 재현한다.
import re
from app.llm.provider import Classification, LLMProvider
RE_TIME = re.compile(
r"(\d{1,2}\s*시|\d{1,2}:\d{2}|오전|오후|"
r"월요일|화요일|수요일|목요일|금요일|토요일|일요일|"
r"월요|화요|수요|목요|금요|토요|일요|내일|모레|오늘)"
)
RE_ACTION = re.compile(r"(사기|구매|하기|맡기기|알아보기|보내기|작성|정리|예약|받|비교|확인|회신|준비)")
RE_WORK = re.compile(r"(미팅|회의|리포트|온보딩|OKR|리뷰|배포|스프린트|기획|디자인|개발|보고|발표)")
RE_LIFE = re.compile(r"(가족|여행|선물|구독|병원|생신|자전거|집|수영장|운동|독서|엄마|아빠|티켓)")
class FakeLLMProvider(LLMProvider):
name = "fake"
def health(self) -> dict:
return {"reachable": True, "provider": "fake", "model": "rules", "detail": "test fake"}
# ── generate_json: 스키마로 분기(메일 분석 / 회신 초안 / 기타) ──
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
props = (schema or {}).get("properties", {})
if "summary" in props and "priority" in props:
return self._analyze(prompt)
if set(props) == {"body"}:
return {"body": "안녕하세요,\n\n메일 확인했습니다. 빠르게 회신드리겠습니다.\n\n감사합니다."}
return {}
def _analyze(self, prompt: str) -> dict:
m = re.search(r"제목:\s*(.+)", prompt)
subject = (m.group(1).strip() if m else "")
tasks, events = [], []
if re.search(r"\d{1,2}시|\d{1,2}:\d{2}", prompt):
events.append(
{"title": subject[:24], "date": "", "day": "", "time": "", "dur": "", "place": ""}
)
if re.search(r"부탁|검토|회신|피드백|정리|확인", prompt):
tasks.append({"text": subject[:30], "project": "받은편지함", "due": "", "prio": "보통"})
prio = "높음" if ("[중요]" in subject or "마감" in prompt) else "보통"
return {
"summary": subject[:60],
"priority": prio,
"category": "메일",
"tasks": tasks,
"events": events,
"replies": [],
"file": None,
}
# ── classify_capture: 인박스 캡처 분류(구 휴리스틱 규칙 동일) ──
def classify_capture(self, raw: str, context: dict) -> Classification:
text = raw or ""
has_clock = bool(re.search(r"(\d{1,2}\s*시|\d{1,2}:\d{2})", text))
has_action = bool(RE_ACTION.search(text))
has_time = bool(RE_TIME.search(text))
if has_clock:
ctype = "event"
elif has_action:
ctype = "task"
elif has_time and not has_action:
ctype = "event"
else:
ctype = "idea"
if RE_WORK.search(text):
sphere = "work"
elif RE_LIFE.search(text):
sphere = "life"
else:
sphere = "work"
project_id, proj_label, tone = self._match_project(text, sphere, context)
due_text, when_text = self._time_hints(text)
extra = "가격 추적 알림 켜둠" if re.search(r"(티켓|구매|가격)", text) else ""
reason = self._reason(ctype, sphere, proj_label)
conf = 0.85 if has_clock or has_action else 0.6
return Classification(
type=ctype,
sphere=sphere,
project_id=project_id,
proj_label=proj_label,
tone=tone,
due_text=due_text,
when_text=when_text,
extra=extra,
reason=reason,
confidence=conf,
model="fake",
)
def _match_project(self, text, sphere, context):
rules = [
(r"(여행|티켓|비행기|한국)", "life-trip", "개인 여행 — 한국", "coral"),
(r"(엄마|아빠|가족|생신|부모)", "life-fam", "가족", "green"),
(r"(온보딩)", "onb", "온보딩 리디자인 · 아이디어 보드", "violet"),
(r"(자전거|수리|병원|예약)", None, "개인 캘린더", "blue"),
]
for pat, pid, label, tone in rules:
if re.search(pat, text):
return pid, label, tone
if sphere == "life":
return "me", "개인 일상", "blue"
return None, "업무 받은 작업", "ink"
def _time_hints(self, text):
m = re.search(r"(월|화|수|목|금|토|일)요일?\s*(\d{1,2})\s*시", text)
if m:
return "", f"{m.group(1)} {int(m.group(2)):02d}:00"
if "다음 주" in text:
return "출발 전 · ~6/14", "오늘 21:00 빈 시간 추천"
return "", ""
def _reason(self, ctype, sphere, proj_label):
sph = "업무" if sphere == "work" else "개인"
if ctype == "event":
return "시간이 정해진 일은 작업이 아니라 일정으로 바로 등록해요."
if ctype == "task":
return (
f"행동이 있으니 '작업' 맞아요. {sph} 트리의 '{proj_label}'에 넣었어요 — "
"따로 섹션이 생기는 게 아니라 다른 작업과 똑같이 보여요."
)
return "아직 행동이 정해지지 않아 아이디어 보드에 보관했어요."

@ -1,20 +1,14 @@
# backend/tests/conftest.py # backend/tests/conftest.py
import os import pytest
from fastapi.testclient import TestClient
# 테스트는 RAG 임베딩을 heuristic(결정적·오프라인)으로 강제 — Ollama embeddings 호출 회피.
os.environ.setdefault("EMBED_PROVIDER", "heuristic")
os.environ.setdefault("AGENT_PROVIDER", "scripted")
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlmodel import Session, SQLModel, create_engine from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool from sqlmodel.pool import StaticPool
from app import seed as seed_mod from app import seed as seed_mod
from app.db import get_session from app.db import get_session
from app.llm.heuristic import HeuristicProvider
from app.llm.provider import get_provider from app.llm.provider import get_provider
from app.main import app from app.main import app
from tests._fake_llm import FakeLLMProvider
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -27,6 +21,27 @@ def _clear_llm_overlay():
runtime_config.clear() runtime_config.clear()
@pytest.fixture(autouse=True)
def _neutralize_dev_env(monkeypatch):
"""개발용 .env(실 OAuth 자격·CONNECTOR_*=real)가 테스트 결정성을 깨지 않도록 기본값으로 되돌린다.
env 값이 .env 파일보다 우선하므로 명시적으로 덮어써서 CI 기본(미설정·mock) 보장한다."""
for k in (
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
):
monkeypatch.setenv(k, "")
for dom in ("CALENDAR", "MAIL"):
monkeypatch.setenv(f"CONNECTOR_{dom}", "mock")
monkeypatch.setenv("AUTO_SYNC_ENABLED", "false") # 백그라운드 풀링 비활성(결정성)
from app.config import get_settings
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture() @pytest.fixture()
def session(): def session():
engine = create_engine( engine = create_engine(
@ -50,7 +65,7 @@ def client(session):
app.dependency_overrides[get_session] = _get_session app.dependency_overrides[get_session] = _get_session
# LLM은 기본적으로 heuristic 강제(테스트 결정성). # LLM은 기본적으로 heuristic 강제(테스트 결정성).
app.dependency_overrides[get_provider] = lambda: HeuristicProvider() app.dependency_overrides[get_provider] = lambda: FakeLLMProvider()
yield TestClient(app) yield TestClient(app)
app.dependency_overrides.clear() app.dependency_overrides.clear()
@ -72,7 +87,7 @@ def client_auth(session, monkeypatch):
yield ss yield ss
app.dependency_overrides[get_session] = _get_session app.dependency_overrides[get_session] = _get_session
app.dependency_overrides[get_provider] = lambda: HeuristicProvider() app.dependency_overrides[get_provider] = lambda: FakeLLMProvider()
yield TestClient(app) yield TestClient(app)
app.dependency_overrides.clear() app.dependency_overrides.clear()
get_settings.cache_clear() get_settings.cache_clear()

@ -1,4 +1,8 @@
# backend/tests/test_api_journey.py # backend/tests/test_api_journey.py
# phase-16+: 작업 시드 제거 → 여정 카드가 참조하는 작업(k1/k3)은 테스트가 직접 생성.
from tests._factories import make_task
def test_journey_shape(client): def test_journey_shape(client):
j = client.get("/api/journey").json() j = client.get("/api/journey").json()
assert [st["id"] for st in j["stages"]] == ["s1", "s2", "s3", "s4"] assert [st["id"] for st in j["stages"]] == ["s1", "s2", "s3", "s4"]
@ -21,7 +25,12 @@ def test_journey_links_seed(client):
) )
def test_journey_cards_live_task(client): def test_journey_cards_live_task(client, session):
s, _ = session
make_task(s, id="k1", title="분기 리포트 초안 마무리", project_id="biz-report",
status="doing", assignee_id="jiwoo")
make_task(s, id="k3", title="팀 환영 준비", project_id="team", status="done",
assignee_id="jiwoo")
j = client.get("/api/journey").json() j = client.get("/api/journey").json()
report = next(c for c in j["cards"]["s2"] if c["node"] == "t-report") report = next(c for c in j["cards"]["s2"] if c["node"] == "t-report")
assert report["title"] == "분기 리포트 초안 마무리" and report["who"] == "jiwoo" assert report["title"] == "분기 리포트 초안 마무리" and report["who"] == "jiwoo"
@ -29,7 +38,10 @@ def test_journey_cards_live_task(client):
assert welcome["done"] is True # k3 status=done assert welcome["done"] is True # k3 status=done
def test_journey_status_reflects_task_update(client): def test_journey_status_reflects_task_update(client, session):
s, _ = session
make_task(s, id="k1", title="분기 리포트 초안 마무리", project_id="biz-report",
status="doing", assignee_id="jiwoo")
client.patch("/api/tasks/k1", json={"status": "done"}) client.patch("/api/tasks/k1", json={"status": "done"})
j = client.get("/api/journey").json() j = client.get("/api/journey").json()
report = next(c for c in j["cards"]["s2"] if c["node"] == "t-report") report = next(c for c in j["cards"]["s2"] if c["node"] == "t-report")

@ -5,7 +5,8 @@ import zipfile
def test_export_zip_has_bundles_and_redacts(client): def test_export_zip_has_bundles_and_redacts(client):
# 데모(지우) — 인증 없이 current_user=지우 # 데모(지우) — 인증 없이 current_user=지우. phase-16+: 작업 시드 제거 → 하나 생성.
client.post("/api/tasks", json={"title": "내보내기 작업", "project_id": "me"})
raw = client.get("/api/me/export").content raw = client.get("/api/me/export").content
z = zipfile.ZipFile(io.BytesIO(raw)) z = zipfile.ZipFile(io.BytesIO(raw))
names = set(z.namelist()) names = set(z.namelist())

@ -1,10 +1,22 @@
"""연합 시나리오 통합 테스트 — 캡처→분류→확인→tasks 등장→risks→dashboard 집계. """연합 시나리오 통합 테스트 — 캡처→분류→확인→tasks 등장→risks→dashboard 집계.
LLM은 HeuristicProvider로 고정(conftest, 결정론적). golden case 기준. LLM은 FakeLLMProvider로 고정(conftest, 결정론적). golden case 기준.
phase-16+: 작업 시드 제거 리스크 테스트는 작업을 직접 생성.
""" """
from datetime import date
from tests._factories import make_task
GOLDEN_RAW = "다음 주에 한국 놀러가는 비행기 티켓 사기" GOLDEN_RAW = "다음 주에 한국 놀러가는 비행기 티켓 사기"
def _seed_risk(s):
make_task(s, id="k1", title="분기 리포트 초안 마무리", project_id="biz-report",
status="doing", due=date(2026, 6, 8), assignee_id="jiwoo")
make_task(s, id="dep-a", title="예산 섹션 작성", project_id="biz-report", status="todo")
make_task(s, id="dep-b", title="경영진 검토 요청 메일", project_id="biz-report",
status="todo", due=date(2026, 6, 10))
def _flatten(nodes): def _flatten(nodes):
out = [] out = []
for n in nodes: for n in nodes:
@ -65,8 +77,10 @@ def test_confirm_idempotent(client):
assert inbox[iid]["materialized_task_id"] == first_tid assert inbox[iid]["materialized_task_id"] == first_tid
def test_risk_recompute_after_task_change(client): def test_risk_recompute_after_task_change(client, session):
"""② 작업 데이터 → 리스크 레이더 자동 계산(최대 3건, TODAY=8)""" """② 작업 데이터 → 리스크 레이더 자동 계산(최대 3건, TODAY=8)"""
s, _ = session
_seed_risk(s)
risks = client.get("/api/risks", params={"area": "work"}).json() risks = client.get("/api/risks", params={"area": "work"}).json()
assert len(risks) <= 3 assert len(risks) <= 3
kinds = [r["kind"] for r in risks] kinds = [r["kind"] for r in risks]
@ -78,8 +92,10 @@ def test_risk_recompute_after_task_change(client):
assert dep["tone"] == "violet" and dep["icon"] == "link" assert dep["tone"] == "violet" and dep["icon"] == "link"
def test_risk_disappears_when_done(client): def test_risk_disappears_when_done(client, session):
"""k1(분기 리포트)을 done 으로 PATCH 후 → 지연 위험에서 k1 사라짐(재계산)""" """k1(분기 리포트)을 done 으로 PATCH 후 → 지연 위험에서 k1 사라짐(재계산)"""
s, _ = session
_seed_risk(s)
before = client.get("/api/risks", params={"area": "work"}).json() before = client.get("/api/risks", params={"area": "work"}).json()
delay_before = next((r for r in before if r["kind"] == "지연 위험"), None) delay_before = next((r for r in before if r["kind"] == "지연 위험"), None)
assert delay_before and delay_before["task_id"] == "k1" assert delay_before and delay_before["task_id"] == "k1"

@ -4,7 +4,19 @@ from app import seed as seedmod
from app.automation.event_bus import bus from app.automation.event_bus import bus
from app.connectors.base import RawRecord from app.connectors.base import RawRecord
from app.connectors.mail.real_gmail import GmailConnector from app.connectors.mail.real_gmail import GmailConnector
from app.models import ConnectorAccount from app.models import ConnectorAccount, ConnectorDomain, ConnectorMode, ConnState
def _mail_account(s):
# phase-16+: 메일 mock 계정 시드 제거 → real 연결 계정을 직접 생성.
a = ConnectorAccount(
id="ca-mail-personal", domain=ConnectorDomain.mail, provider="gmail",
mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com",
name="me@gmail.com",
)
s.add(a)
s.commit()
return a
class FakeGmailConnector(GmailConnector): class FakeGmailConnector(GmailConnector):
@ -40,7 +52,7 @@ _MSG = {
def test_real_sync_publishes_mail_received_once(session): def test_real_sync_publishes_mail_received_once(session):
s, _ = session s, _ = session
seedmod.run_seed(session=s, reset=True) seedmod.run_seed(session=s, reset=True)
acct = s.get(ConnectorAccount, "ca-mail-personal") acct = _mail_account(s)
before = len([e for e in bus.history if e.type == "mail.received"]) before = len([e for e in bus.history if e.type == "mail.received"])
conn = FakeGmailConnector(acct) conn = FakeGmailConnector(acct)
@ -61,7 +73,7 @@ def test_real_sync_publishes_mail_received_once(session):
def test_real_sync_failure_does_not_crash(session): def test_real_sync_failure_does_not_crash(session):
s, _ = session s, _ = session
seedmod.run_seed(session=s, reset=True) seedmod.run_seed(session=s, reset=True)
acct = s.get(ConnectorAccount, "ca-mail-personal") acct = _mail_account(s)
class BoomConnector(GmailConnector): class BoomConnector(GmailConnector):
def fetch(self, session, *, full=False): def fetch(self, session, *, full=False):

@ -36,23 +36,22 @@ def test_create_owner_is_current_user_not_body(client_auth):
def test_b_starts_empty_a_full(client_auth): def test_b_starts_empty_a_full(client_auth):
# 시드 데이터는 전부 지우 소유 → 현우는 빈 상태에서 시작 # phase-16+: 작업 시드 제거 → A(지우)가 작업을 만들면 A 만 보이고 B(현우)는 빈 상태
_login(client_auth, "jiwoo@lumi.co") _login(client_auth, "jiwoo@lumi.co")
assert len(client_auth.get("/api/tasks").json()) > 0 # 지우는 시드 작업 보임 client_auth.post("/api/tasks", json={"title": "지우 작업", "project_id": "me"})
assert len(client_auth.get("/api/tasks").json()) > 0 # 지우는 자기 작업 보임
client_auth.post("/api/auth/logout") client_auth.post("/api/auth/logout")
_login(client_auth, "hyunwoo@lumi.co") _login(client_auth, "hyunwoo@lumi.co")
assert client_auth.get("/api/tasks").json() == [] assert client_auth.get("/api/tasks").json() == []
assert client_auth.get("/api/inbox").json() == [] assert client_auth.get("/api/inbox").json() == []
assert client_auth.get("/api/mail").json() == [] assert client_auth.get("/api/mail").json() == []
assert client_auth.get("/api/research/home").json()["collections"] == []
assert client_auth.get("/api/trip/upcoming").status_code == 404 # 현우 여행 없음
notif = client_auth.get("/api/notifications").json() notif = client_auth.get("/api/notifications").json()
assert notif["now"] == [] and notif["later"] == [] and notif["held"] == [] assert notif["now"] == [] and notif["later"] == [] and notif["held"] == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path", ["/api/tasks", "/api/inbox", "/api/mail", "/api/research/home", "/api/notifications"] "path", ["/api/tasks", "/api/inbox", "/api/mail", "/api/notifications"]
) )
def test_b_lists_scoped_no_a_leak(client_auth, path): def test_b_lists_scoped_no_a_leak(client_auth, path):
_login(client_auth, "hyunwoo@lumi.co") _login(client_auth, "hyunwoo@lumi.co")

@ -2,7 +2,7 @@
import pytest import pytest
from app.automation.nl_parser import parse_rule from app.automation.nl_parser import parse_rule
from app.llm.heuristic import HeuristicProvider from tests._fake_llm import FakeLLMProvider
GOLDEN = [ GOLDEN = [
( (
@ -39,7 +39,7 @@ def test_golden_examples(text, cat, trig, cond, action):
def test_heuristic_fallback_classifies_cat(): def test_heuristic_fallback_classifies_cat():
# examples 미일치 → 휴리스틱(명시 provider 로 환경(Ollama 가동 여부) 무관 결정적) # examples 미일치 → 휴리스틱(명시 provider 로 환경(Ollama 가동 여부) 무관 결정적)
r = parse_rule("영수증 들어오면 자동으로 정리해줘", provider=HeuristicProvider()) r = parse_rule("영수증 들어오면 자동으로 정리해줘", provider=FakeLLMProvider())
assert r.matched and r.cat == "life" and r.model == "heuristic" assert r.matched and r.cat == "life" and r.model == "heuristic"

@ -0,0 +1,185 @@
# backend/tests/test_phase18_features.py — phase-18 신규 기능
# 메일(검색·일괄·라벨·cc) · 일정(반복·알림·RSVP) · 작업(정렬·댓글 수정/삭제) · 반복규칙 변환
from app.connectors.calendar.normalize import classify_event
from app.connectors.calendar.recurrence import (
graph_to_rrule,
parse_rrule,
rrule_to_google,
rrule_to_graph,
)
from tests._factories import make_comment, make_email, make_mail_account, make_task
# ── 일정 카테고리 분류(공휴일/생일/Gmail) ──
def test_classify_google_birthday_and_gmail():
assert classify_event("google_calendar", {"eventType": "birthday"}, "축하") == "cat-birthday"
assert classify_event("google_calendar", {"eventType": "fromGmail"}, "예약") == "cat-gmail"
assert classify_event("google_calendar", {"eventType": "default"}, "주간 회의") is None
def test_classify_by_title_heuristic():
assert classify_event("google_calendar", {"eventType": "default"}, "유나 생일") == "cat-birthday"
assert classify_event("google_calendar", {"eventType": "default"}, "추석 연휴") == "cat-holiday"
def test_classify_outlook_categories():
p = {"categories": ["Holiday"]}
assert classify_event("outlook_calendar", p, "Christmas") == "cat-holiday"
# ── 반복 규칙(RRULE) 변환: Google/Graph 양방향 ──
def test_rrule_to_google_wraps_prefix():
assert rrule_to_google("FREQ=WEEKLY;BYDAY=MO") == ["RRULE:FREQ=WEEKLY;BYDAY=MO"]
assert rrule_to_google("") == []
def test_rrule_graph_roundtrip_weekly():
g = rrule_to_graph("FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2", "2026-06-15")
assert g["pattern"]["type"] == "weekly"
assert g["pattern"]["interval"] == 2
assert set(g["pattern"]["daysOfWeek"]) == {"monday", "wednesday"}
back = graph_to_rrule(g)
r = parse_rrule(back)
assert r["FREQ"] == "WEEKLY" and r["INTERVAL"] == "2"
assert set(r["BYDAY"]) == {"MO", "WE"}
def test_rrule_to_graph_monthly_uses_day_of_month():
g = rrule_to_graph("FREQ=MONTHLY", "2026-06-15")
assert g["pattern"]["type"] == "absoluteMonthly"
assert g["pattern"]["dayOfMonth"] == 15
def test_rrule_to_graph_count_range():
g = rrule_to_graph("FREQ=DAILY;COUNT=5", "2026-06-15")
assert g["range"]["type"] == "numbered"
assert g["range"]["numberOfOccurrences"] == 5
# ── 메일 서버측 검색 ──
def test_mail_search_filters_by_query(client, session):
s, _ = session
make_mail_account(s, id="work")
make_email(s, id="m1", subject="분기 리포트 검토", preview="리포트 초안")
make_email(s, id="m2", subject="점심 약속", preview="강남에서 봐요")
hits = client.get("/api/mail?q=리포트").json()
ids = {m["id"] for m in hits}
assert ids == {"m1"}
# ── 메일 일괄 처리 ──
def test_mail_bulk_mark_read(client, session):
s, _ = session
make_mail_account(s, id="work")
make_email(s, id="m1", read=False)
make_email(s, id="m2", read=False)
r = client.post("/api/mail/bulk", json={"ids": ["m1", "m2"], "action": "read"})
assert r.status_code == 200 and r.json()["count"] == 2
rows = {m["id"]: m for m in client.get("/api/mail").json()}
assert rows["m1"]["read"] is True and rows["m2"]["read"] is True
def test_mail_bulk_trash_removes(client, session):
s, _ = session
make_mail_account(s, id="work")
make_email(s, id="m1")
client.post("/api/mail/bulk", json={"ids": ["m1"], "action": "trash"})
assert client.get("/api/mail/m1").status_code == 404
def test_mail_bulk_invalid_action_400(client, session):
s, _ = session
make_email(s, id="m1")
assert client.post("/api/mail/bulk", json={"ids": ["m1"], "action": "nope"}).status_code == 400
# ── 메일 라벨 추가/제거 ──
def test_mail_labels_add_remove(client, session):
s, _ = session
make_mail_account(s, id="work")
make_email(s, id="m1", labels=["기존"])
r = client.patch("/api/mail/m1/labels", json={"add": ["중요"], "remove": ["기존"]})
assert r.status_code == 200
assert r.json()["labels"] == ["중요"]
# ── 메일 cc 직렬화 ──
def test_mail_detail_exposes_cc(client, session):
s, _ = session
make_mail_account(s, id="work")
make_email(s, id="m1")
e = client.get("/api/mail/m1").json()
assert "cc" in e and "thread_id" in e
# ── 일정 생성: 반복 + 알림 (로컬) ──
def test_create_event_with_recurrence_and_reminders(client):
body = {
"title": "주간 회의",
"date": "2026-06-17",
"start": "10:00",
"end": "10:30",
"rrule": "FREQ=WEEKLY",
"reminders": [10, 30],
"people": ["a@b.com"],
}
ev = client.post("/api/calendar/events", json=body).json()
assert ev["rrule"] == "FREQ=WEEKLY"
assert ev["reminders"] == [10, 30]
assert ev["attendees"][0]["email"] == "a@b.com"
# ── 일정 RSVP (로컬) ──
def test_event_rsvp_updates_response(client):
body = {"title": "초대받은 회의", "date": "2026-06-17", "start": "14:00", "end": "15:00",
"people": ["host@x.com"]}
ev = client.post("/api/calendar/events", json=body).json()
r = client.post(f"/api/calendar/events/{ev['id']}/rsvp", json={"status": "accepted"})
assert r.status_code == 200
assert r.json()["response_status"] == "accepted"
def test_event_rsvp_invalid_status_400(client):
body = {"title": "회의", "date": "2026-06-17", "start": "14:00", "end": "15:00"}
ev = client.post("/api/calendar/events", json=body).json()
assert client.post(
f"/api/calendar/events/{ev['id']}/rsvp", json={"status": "maybe"}
).status_code == 400
# ── 작업 드래그 정렬 ──
def test_task_reorder_sets_sort_order(client, session):
s, _ = session
make_task(s, id="t1", project_id="me", sort_order=0)
make_task(s, id="t2", project_id="me", sort_order=1)
make_task(s, id="t3", project_id="me", sort_order=2)
r = client.post("/api/tasks/reorder", json={"ids": ["t3", "t1", "t2"]})
assert r.status_code == 200 and r.json()["count"] == 3
order = {t["id"]: t["sort_order"] for t in client.get("/api/tasks").json()}
assert order["t3"] == 0 and order["t1"] == 1 and order["t2"] == 2
# ── 댓글 수정/삭제 ──
def test_comment_edit_sets_edited_at(client, session):
s, _ = session
make_task(s, id="t1", project_id="me")
make_comment(s, id="c1", task_id="t1", text="원본")
r = client.patch("/api/tasks/t1/comments/c1", json={"text": "수정됨"})
assert r.status_code == 200
assert r.json()["text"] == "수정됨" and r.json()["edited_at"] is not None
def test_comment_delete(client, session):
s, _ = session
make_task(s, id="t1", project_id="me")
make_comment(s, id="c1", task_id="t1")
assert client.delete("/api/tasks/t1/comments/c1").status_code == 200
node = client.get("/api/tasks/t1").json()
assert node["comments"] == []
def test_comment_edit_wrong_task_404(client, session):
s, _ = session
make_task(s, id="t1", project_id="me")
make_comment(s, id="c1", task_id="t1")
assert client.patch("/api/tasks/t9/comments/c1", json={"text": "x"}).status_code == 404

@ -1,4 +1,20 @@
def test_risk_delay_present(client): # backend/tests/test_risk.py — 리스크 표시 (phase-16+: 작업 시드 제거 → 테스트가 직접 생성)
from datetime import date
from tests._factories import make_task
def _seed(s):
make_task(s, id="k1", title="분기 리포트 초안 마무리", project_id="biz-report",
status="doing", due=date(2026, 6, 8), assignee_id="jiwoo")
make_task(s, id="dep-a", title="예산 섹션 작성", project_id="biz-report", status="todo")
make_task(s, id="dep-b", title="경영진 검토 요청 메일", project_id="biz-report",
status="todo", due=date(2026, 6, 10))
def test_risk_delay_present(client, session):
s, _ = session
_seed(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
kinds = [x["kind"] for x in r] kinds = [x["kind"] for x in r]
assert "지연 위험" in kinds assert "지연 위험" in kinds
@ -6,20 +22,25 @@ def test_risk_delay_present(client):
assert "마감인데" in delay["text"] and delay["icon"] == "clock" assert "마감인데" in delay["text"] and delay["icon"] == "clock"
def test_risk_dependency(client): def test_risk_dependency(client, session):
s, _ = session
_seed(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
dep = [x for x in r if x["kind"] == "의존성"] dep = [x for x in r if x["kind"] == "의존성"]
assert dep, "예산 섹션 작성→경영진 검토 요청 메일 의존성 1건" assert dep, "예산 섹션 작성→경영진 검토 요청 메일 의존성 1건"
assert "함께 밀려요" in dep[0]["text"] assert "함께 밀려요" in dep[0]["text"]
def test_risk_max_three(client): def test_risk_max_three(client, session):
s, _ = session
_seed(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
assert len(r) <= 3 assert len(r) <= 3
def test_risk_overload_threshold(client): def test_risk_overload_threshold(client, session):
# 시드 기준 업무 쏠림이 topN>=avg*1.5 && topN>=4 를 만족하면 포함 s, _ = session
_seed(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
over = [x for x in r if x["kind"] == "업무 쏠림"] over = [x for x in r if x["kind"] == "업무 쏠림"]
for o in over: for o in over:

@ -1,6 +1,22 @@
# phase-3 §7.2 — 리스크 정확성 (시드 기준) # backend/tests/test_risks.py — 리스크 정확성 (phase-16+: 작업 시드 제거 → 테스트가 직접 생성)
def test_risk_delay_top1(client): from datetime import date
# k1(분기 리포트 초안 마무리, due 6/8, doing) → 지연 위험 1건
from tests._factories import make_task
def _seed_risk_tasks(s):
# 지연 위험: 마감(6/8) 지난 진행중 최상위 작업
make_task(s, id="k1", title="분기 리포트 초안 마무리", project_id="biz-report",
status="doing", due=date(2026, 6, 8), assignee_id="jiwoo")
# 의존성: blocker/blocked 한 쌍(둘 다 미완) — risk.DEPS 의 제목과 일치
make_task(s, id="dep-a", title="예산 섹션 작성", project_id="biz-report", status="todo")
make_task(s, id="dep-b", title="경영진 검토 요청 메일", project_id="biz-report",
status="todo", due=date(2026, 6, 10))
def test_risk_delay_top1(client, session):
s, _ = session
_seed_risk_tasks(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
delay = [x for x in r if x["kind"] == "지연 위험"] delay = [x for x in r if x["kind"] == "지연 위험"]
assert len(delay) == 1 assert len(delay) == 1
@ -10,8 +26,9 @@ def test_risk_delay_top1(client):
assert delay[0]["cta"] == "작업 열기" assert delay[0]["cta"] == "작업 열기"
def test_risk_dependency(client): def test_risk_dependency(client, session):
# 예산 섹션 작성(미완) → 경영진 검토 요청 메일(미완) : violet, cta="후속 작업 보기" s, _ = session
_seed_risk_tasks(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
dep = [x for x in r if x["kind"] == "의존성"] dep = [x for x in r if x["kind"] == "의존성"]
assert len(dep) == 1 and dep[0]["tone"] == "violet" assert len(dep) == 1 and dep[0]["tone"] == "violet"
@ -19,7 +36,9 @@ def test_risk_dependency(client):
assert dep[0]["cta"] == "후속 작업 보기" assert dep[0]["cta"] == "후속 작업 보기"
def test_risk_overload(client): def test_risk_overload(client, session):
s, _ = session
_seed_risk_tasks(s)
r = client.get("/api/risks?area=work").json() r = client.get("/api/risks?area=work").json()
over = [x for x in r if x["kind"] == "업무 쏠림"] over = [x for x in r if x["kind"] == "업무 쏠림"]
assert len(over) <= 1 assert len(over) <= 1
@ -27,7 +46,9 @@ def test_risk_overload(client):
assert over[0]["tone"] == "amber" and over[0]["task_id"] is None assert over[0]["tone"] == "amber" and over[0]["task_id"] is None
def test_risks_max_three(client): def test_risks_max_three(client, session):
s, _ = session
_seed_risk_tasks(s)
assert len(client.get("/api/risks?area=work").json()) <= 3 assert len(client.get("/api/risks?area=work").json()) <= 3

@ -1,41 +0,0 @@
# backend/tests/test_seed_research_trip.py
from sqlmodel import select
from app.models import (
RagChunk,
ResearchChart,
ResearchCollection,
ResearchQA,
ResearchReport,
ResearchSource,
SavedTrip,
Trip,
TripChecklist,
TripDay,
TripPlan,
TripPrep,
TripRoute,
)
def test_research_seed_counts(client, session):
s, _ = session
assert len(s.exec(select(ResearchCollection)).all()) == 3
srcs = s.exec(select(ResearchSource)).all()
assert len(srcs) == 5 and sum(1 for x in srcs if x.learned) == 4
assert len(s.exec(select(ResearchReport)).all()) == 1
assert len(s.exec(select(ResearchQA)).all()) == 1
assert len(s.exec(select(ResearchChart)).all()) == 1
assert len(s.exec(select(RagChunk)).all()) >= 5 # 소스마다 1청크 이상
def test_trip_seed_counts(client, session):
s, _ = session
assert len(s.exec(select(Trip)).all()) == 1
assert len(s.exec(select(TripRoute)).all()) == 2
preps = s.exec(select(TripPrep)).all()
assert len(preps) == 5 and sum(1 for p in preps if p.state == "doing") == 1
assert len(s.exec(select(TripDay)).all()) == 2
assert len(s.exec(select(TripChecklist)).all()) == 7
assert len(s.exec(select(SavedTrip)).all()) == 3
assert len(s.exec(select(TripPlan)).all()) == 2

@ -0,0 +1,128 @@
# Phase 16 — 실제 이메일 계정 연동 (Gmail + Outlook)
> mock 시드 메일을 넘어 **내 Gmail·Outlook 계정을 OAuth로 붙여** 받은편지함을 수집하고,
> 회신/새 메일을 **결재 승인 시 실제 발송**한다. mock 데모는 1바이트도 바뀌지 않는다
> (`CONNECTOR_MAIL=mock` 기본, real은 연결된 계정에만 적용).
이 문서는 **OAuth 앱 자격증명 발급(Google Cloud · Azure) 가이드**와 동작 확인 절차다.
코드는 이미 구현되어 있고, 자격증명만 `backend/.env`에 넣으면 실연동이 켜진다.
---
## 1. 무엇이 동작하나
- **Gmail**: Google OAuth(Authorization Code + PKCE) → Gmail REST API로 받은편지함 증분 수집 + 발송.
- **Outlook / Microsoft 365**: Microsoft Graph OAuth → `/me/messages` 수집 + `/me/sendMail` 발송.
- **Outlook / M365 캘린더**: 같은 Microsoft 앱 등록에 `Calendars.Read` 권한만 더하면
`/me/calendarView`로 일정 수집(메일과 **별개 계정** "Outlook 캘린더 추가"로 붙음, 설정 → 일정 탭).
- **회사 M365**: 개인 Outlook.com과 **같은 앱 하나**로 처리(테넌트 `common`). 회사 계정은
"Outlook 추가"를 한 번 더 눌러 로그인하면 별도 계정으로 붙음. 단 회사가 외부 앱 동의를
잠가뒀으면 "관리자 승인 필요"가 떠서 IT에 이 앱(클라이언트 ID) 승인을 요청해야 함.
- **멀티계정**: 같은 제공자라도 이메일 주소가 다르면 별도 계정으로 추가(덮어쓰기 없음).
- **읽기 + 보내기**: 메일 페이지의 회신/새 메일은 high-risk **결재함**으로 가고, 승인 순간
연결된 real 계정이면 진짜 발송, 미연결이면 보낸편지함(mock) 기록.
> **왜 OAuth인가 (SMTP/IMAP 비밀번호 불가):** Gmail은 2022년 basic-auth(앱 비밀번호 제외)를,
> Outlook.com(개인)은 2024-09-16부로 IMAP/POP/SMTP basic-auth를, M365(직장/학교)는 그 이전에
> 차단했다. Outlook은 OAuth2가 **유일한** 경로라 둘 다 OAuth로 통일했다.
---
## 2. Google Cloud — Gmail OAuth 클라이언트 발급
1. <https://console.cloud.google.com> → 프로젝트 생성(예: `ari-mail`).
2. **API 및 서비스 → 라이브러리****Gmail API** 사용 설정.
3. **OAuth 동의 화면**:
- User Type = **외부(External)**, 앱 이름/지원 이메일 입력 후 저장.
- **게시 상태 = 테스트(Testing)** 로 두고, **테스트 사용자**에 본인 Gmail 주소 추가.
(테스트 모드면 Google 검수 없이 본인 계정으로 바로 사용 가능 — 개인용은 이걸로 충분.)
- 스코프는 굳이 추가 안 해도 됨(요청 시 동적으로 동의 화면에 표시됨).
4. **사용자 인증 정보 → 사용자 인증 정보 만들기 → OAuth 클라이언트 ID**:
- 애플리케이션 유형 = **웹 애플리케이션**.
- **승인된 리디렉션 URI** =
`http://localhost:31800/api/connectors/oauth/callback`
- 만들면 **클라이언트 ID / 클라이언트 보안 비밀** 발급 → 복사.
요청 스코프(코드가 자동 요청): `gmail.readonly`, `gmail.send`
(연결 계정 이메일은 `users/me/profile`로 조회 — 추가 스코프 불필요).
---
## 3. Azure Portal — Outlook(Microsoft Graph) 앱 등록
1. <https://portal.azure.com>**Microsoft Entra ID → 앱 등록 → 새 등록**.
2. **지원되는 계정 유형** = "모든 조직 디렉터리 + 개인 Microsoft 계정"(개인 Outlook.com 포함).
- 이 경우 테넌트는 기본 `common`(아래 `.env` `MICROSOFT_TENANT` 기본값).
3. **리디렉션 URI** = 플랫폼 **웹**,
`http://localhost:31800/api/connectors/oauth/callback`
4. **인증서 및 비밀 → 새 클라이언트 비밀** 생성 → **값(Value)** 복사(이때만 보임).
5. **API 권한 → 권한 추가 → Microsoft Graph → 위임된 권한**:
- 메일: `Mail.Read`, `Mail.Send`, `User.Read`, `offline_access` 추가.
- 일정도 쓰려면: **`Calendars.Read`** 추가(이걸 넣으면 설정 → 일정 탭에 "Outlook 캘린더 추가"가 활성).
- 개인 계정은 관리자 동의 불필요(동의 화면에서 본인이 동의). **회사 M365**는 테넌트 정책에
따라 "관리자 승인 필요"가 뜰 수 있음 → 별도 앱이 아니라 IT에 이 앱 승인을 요청.
6. **개요**에서 **애플리케이션(클라이언트) ID** 복사.
---
## 4. backend/.env 주입
```dotenv
# 토큰 암호화 키(실연동 필수 — 32바이트 이상 임의 문자열)
ARI_SECRET_KEY=<openssl rand -hex 32 >
# real 메일 연동 ON (phase-16+: 메일은 mock 제거됨 — 연결 전까지 빈 상태)
CONNECTOR_MAIL=real
# Google (Gmail)
GOOGLE_CLIENT_ID=<2 >
GOOGLE_CLIENT_SECRET=<2 >
# Microsoft (Outlook)
MICROSOFT_CLIENT_ID=<3 >
MICROSOFT_CLIENT_SECRET=<3 ''>
MICROSOFT_TENANT=common
# google·microsoft 공용 콜백(양쪽 콘솔에 등록한 것과 정확히 일치해야 함)
OAUTH_REDIRECT_URI=http://localhost:31800/api/connectors/oauth/callback
```
> `ARI_SECRET_KEY`를 바꾸면 기존에 암호화 저장된 토큰은 복호화 불가(재연결 필요).
> 프로덕션에선 HTTPS 콜백 URL로 바꾸고 각 콘솔에도 그 URL을 등록한다.
---
## 5. 동작 확인
1. 백엔드 기동: `cd backend && uv run uvicorn app.main:app --port 31800`
프론트 기동: `cd frontend && pnpm dev` (`:31300`).
2. `/settings?tab=mail`**계정 추가** 버튼 → **Gmail**(또는 Outlook) 선택.
- 자격증명이 설정됐으면 항목이 활성화됨(미설정이면 "설정 필요"로 비활성).
3. 제공자 동의 화면 → 허용 → 콜백이 `/settings?tab=mail?connect=ok`로 복귀하며 토스트.
4. 연결 직후 1회 초기 sync로 받은편지함 일부가 수집됨 → **메일 페이지**에 그 계정 탭으로 노출.
5. 다른 이메일로 한 번 더 추가하면 **별도 계정**으로 붙음(멀티계정).
6. 메일 회신/새 메일 작성 → **결재함**에 pending → **승인** → 실제 수신함으로 발송 확인.
(연결 안 된 계정에서 보내면 보낸편지함 mock 기록만 남고 외부 발송은 안 됨 = 데모 안전.)
---
## 6. 구현 노트 (파일 맵)
| 영역 | 파일 | 변경 |
| --- | --- | --- |
| OAuth | `backend/app/connectors/oauth.py` | `microsoft` provider, `_family(outlook→microsoft)`, gmail.send 스코프, `_fetch_identity`(프로필 이메일), 멀티계정 `_ensure_account`+`MailAccount` upsert |
| 설정 | `backend/app/config.py` | `microsoft_client_id/secret`, `microsoft_tenant`, `oauth_redirect_uri` |
| 커넥터 | `backend/app/connectors/mail/real_outlook.py` (신규), `real_gmail.py`(`send_mail`), `normalize.py`(outlook 분기 + real 계정 매핑), `registry.py`(provider별 Gmail/Outlook 분기) | |
| 발송 | `backend/app/models.py`(`OutboundMail`), 마이그레이션 `p17e5f6a7b8c9`, `routers/mail.py`(`send()`), `connectors/mail/outbound.py`(`send_outbound`), `worker/main.py`(approval.executed 구독) | |
| 라우터 | `backend/app/routers/connectors.py` | `/connectors/providers`, oauth_start outlook 검증, 콜백 `redirect_after` 반영 |
| 프론트 | `frontend/components/connectors/AddAccountMenu.tsx`(신규), `settings/ConnectorList.tsx`·`MailTab.tsx`·`SettingsClient.tsx`(connect 토스트), `lib/connectors/api.ts`(`providers`/`oauthStart`), `lib/types.ts` | |
**설계 원칙**
- `effective_mode`: 시드 mock 계정은 env=real이어도 mock 유지(데모 결정성). real은 **연결된 계정**에만.
- 토큰은 `app/crypto.py` Fernet로 암호화 저장(평문 금지), 만료 시 `valid_access_token`이 refresh.
- 수집은 `ExternalLink` 기준 멱등 upsert(중복 없음), 발송 실패는 `OutboundMail.status=failed`로 격리.
- 발송 훅은 동기 event_bus 구독이라 `WORKER_ENABLED=false`(기본) 데모에서도 승인 즉시 처리.
테스트는 lifespan 미가동이라 자동 발송 안 됨 → `send_outbound` 헬퍼를 직접 단위 테스트.
## 7. 범위 밖(후속)
첨부 다운로드/발송, 라벨 양방향 동기화, push/webhook(현재 polling), 프로덕션 OAuth 검수, 멀티유저.

@ -9,15 +9,12 @@ const AXE = (page: import("@playwright/test").Page) =>
const PAGES: [string, string][] = [ const PAGES: [string, string][] = [
["/dashboard", "아리 브리핑"], ["/dashboard", "아리 브리핑"],
["/inbox", "스마트 인박스"], ["/inbox", "스마트 인박스"],
["/tasks", "리스크 레이더"], ["/tasks", "칸반"], // phase-16+: 빈 보드엔 리스크 레이더 없음 → 항상 있는 뷰 토글로 마커
["/approvals", "아리 결재함"], ["/approvals", "아리 결재함"],
["/automation", "내 규칙"], ["/automation", "내 규칙"],
["/calendar", "내 캘린더"], ["/calendar", "내 캘린더"],
["/mail", "받은편지함"], ["/mail", "받은편지함"],
["/notifications", "트리아지"], ["/notifications", "트리아지"],
["/research", "새 조사"],
["/trip", "다가오는 출장"],
["/life", "연결된 데이터"],
["/journey", "오늘의 여정"], ["/journey", "오늘의 여정"],
]; ];

@ -1,5 +1,5 @@
// frontend/playwright/calendar.spec.ts — 일정/회의 E2E (액션→작업 왕복 + a11y) // frontend/playwright/calendar.spec.ts — 일정 E2E
// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정. // phase-16+: 일정 mock 시드 제거 → 빈 상태. 실데이터는 Google 캘린더 연결 후 유입(OAuth, e2e 범위 밖).
import AxeBuilder from "@axe-core/playwright"; import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { resetSeed } from "./fixtures/seed-reset"; import { resetSeed } from "./fixtures/seed-reset";
@ -10,54 +10,25 @@ test.beforeEach(async () => {
await resetSeed(); await resetSeed();
}); });
test("주 뷰 기본 + 일 뷰 전환 + 회의 진입 버튼", async ({ page }) => { test("주 뷰 기본 + 일 뷰 전환(빈 일정)", async ({ page }) => {
await page.goto("/calendar"); await page.goto("/calendar");
await expect(page.getByRole("heading", { name: "6월 7일 13일" })).toBeVisible(); await expect(page.getByRole("heading", { name: "6월 7일 13일" })).toBeVisible();
await page.getByRole("tab", { name: "일" }).click(); await page.getByRole("tab", { name: "일" }).click();
await expect(page.getByRole("button", { name: /회의 노트 · 액션 보기/ }).first()).toBeVisible(); // 일정 시드 제거 → 회의 진입 버튼 없음
await expect(page.getByRole("button", { name: /회의 노트 · 액션 보기/ })).toHaveCount(0);
}); });
test("회의 액션 → 작업 페이지 등장 (federation)", async ({ page }) => { test("일정 계정 연결 진입점은 설정 연결 탭", async ({ page }) => {
await page.goto("/calendar"); await page.goto("/settings?tab=connect");
await page.getByRole("tab", { name: "일" }).click(); await expect(page.getByRole("button", { name: /계정 추가/ })).toBeVisible();
await page.getByRole("button", { name: /회의 노트 · 액션 보기/ }).first().click();
await expect(page.getByRole("dialog", { name: "회의 도우미" })).toBeVisible();
await expect(page.getByText("매출 데이터 전달")).toBeVisible();
// e3 는 미처리 액션이 '매출 데이터 전달' 1건 → 클릭 후 '작업으로' 버튼이 사라짐
await page.getByRole("button", { name: "작업으로", exact: true }).click();
await expect(page.getByRole("button", { name: "작업으로", exact: true })).toHaveCount(0);
await expect(page.getByText("작업에 있음").first()).toBeVisible();
// 작업 페이지에서 확인
await page.goto("/tasks");
await expect(page.getByText("매출 데이터 전달").first()).toBeVisible();
}); });
test("사전 브리핑 드로어 a11y (axe)", async ({ page }) => { test("빈 일정 페이지 a11y — 위반 0건", async ({ page }) => {
await page.goto("/calendar"); await page.goto("/calendar");
await page.getByRole("tab", { name: "일" }).click(); await expect(page.locator(".calpage")).toBeVisible();
await page.getByRole("button", { name: /사전 브리핑 보기/ }).first().click(); const r = await new AxeBuilder({ page })
await expect(page.getByRole("dialog")).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"]) .withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(["color-contrast"]) .disableRules(["color-contrast"])
.include('[role="dialog"]')
.analyze(); .analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]); expect(r.violations, JSON.stringify(r.violations, null, 2)).toEqual([]);
});
test("Escape 로 드로어 닫힘", async ({ page }) => {
await page.goto("/calendar");
await page.getByRole("tab", { name: "일" }).click();
await page.getByRole("button", { name: /1:1 어시스턴트 브리핑/ }).first().click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();
});
test("카테고리 토글 off → 해당 이벤트 숨김", async ({ page }) => {
await page.goto("/calendar");
await page.getByRole("tab", { name: "일" }).click();
// 건강 카테고리 off (e8 스트레칭 & 명상이 8일에 있음)
await page.getByRole("button", { name: /건강/ }).first().click();
await expect(page.getByText("스트레칭 & 명상")).toHaveCount(0);
}); });

@ -9,15 +9,15 @@ test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await resetSeed(); await resetSeed();
await page.goto("/life"); // 설정 페이지의 '연결' 탭에서 연결 관리 (라이프 페이지 제거됨)
// 라이프 모듈 레일에서 '연결' 모듈로 전환 await page.goto("/settings?tab=connect");
await page.locator(".subnav .mod-row", { hasText: "외부 데이터 소스" }).click();
await expect(page.getByRole("heading", { name: "연결된 데이터 소스" })).toBeVisible(); await expect(page.getByRole("heading", { name: "연결된 데이터 소스" })).toBeVisible();
}); });
test("14개 커넥터 카드 + 12/14 연결됨 + 미연결 카드 표시", async ({ page }) => { test("10개 커넥터 카드 + 8/10 연결됨 + 미연결 카드 표시", async ({ page }) => {
await expect(page.locator(".cn-grid .cn-card")).toHaveCount(14); // phase-16+: 메일/일정 mock 제거 → 금융·건강·지식 10개만
await expect(page.locator(".cn-count")).toHaveText("12/14 연결됨"); await expect(page.locator(".cn-grid .cn-card")).toHaveCount(10);
await expect(page.locator(".cn-count")).toHaveText("8/10 연결됨");
// Google Fit / KB증권 은 미연결(off + '연결 안 됨') // Google Fit / KB증권 은 미연결(off + '연결 안 됨')
const fit = page.locator(".cn-card", { hasText: "Google Fit" }); const fit = page.locator(".cn-card", { hasText: "Google Fit" });
@ -31,8 +31,8 @@ test("14개 커넥터 카드 + 12/14 연결됨 + 미연결 카드 표시", async
}); });
test("동기화 → 토스트 노출", async ({ page }) => { test("동기화 → 토스트 노출", async ({ page }) => {
const work = page.locator(".cn-card", { hasText: "회사" }).first(); const woori = page.locator(".cn-card", { hasText: "우리카드" }).first();
await work.getByRole("button", { name: /동기화/ }).click(); await woori.getByRole("button", { name: /동기화/ }).click();
await expect(page.locator(".au-toast")).toContainText("새 항목"); await expect(page.locator(".au-toast")).toContainText("새 항목");
}); });

@ -32,19 +32,19 @@ test("결재함 카드 CTA → /approvals 실제 페이지(phase-7)", async ({ p
await expect(page.getByRole("heading", { level: 1, name: /아리 결재함/ })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: /아리 결재함/ })).toBeVisible();
}); });
test("작업 요약 항목 클릭 → /tasks?task= 딥링크", async ({ page }) => { // phase-16+: 작업 시드 제거 → 작업 요약 딥링크 테스트는 빈 상태라 제외.
await page.goto("/dashboard");
await page.locator(".task-title", { hasText: "분기 리포트 초안 마무리" }).click();
await expect(page).toHaveURL(/\/tasks\?task=/);
});
test("자연어 명령 입력 → 캡처 후 /inbox 이동", async ({ page }) => { test("자연어 명령 입력 → 캡처 후 /inbox 이동", async ({ page }) => {
test.setTimeout(150_000); // 실 Ollama 콜드스타트 여유
await page.goto("/dashboard"); await page.goto("/dashboard");
const input = page.getByLabel("아리에게 명령 입력"); const input = page.getByLabel("아리에게 명령 입력");
await input.fill("수요일 11시 자전거 수리 맡기기"); await input.fill("수요일 11시 자전거 수리 맡기기");
await page.getByLabel("보내기").click(); await page.getByLabel("보내기").click();
await expect(page).toHaveURL(/\/inbox$/); // 실 Ollama 분류(수초) 후 이동 → 넉넉한 타임아웃
await expect(page.locator(".sb-raw", { hasText: "수요일 11시 자전거 수리 맡기기" }).first()).toBeVisible(); await expect(page).toHaveURL(/\/inbox$/, { timeout: 30000 });
await expect(
page.locator(".sb-raw", { hasText: "수요일 11시 자전거 수리 맡기기" }).first(),
).toBeVisible({ timeout: 30000 });
}); });
test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => { test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => {

@ -1,7 +1,6 @@
// frontend/playwright/federation.spec.ts // frontend/playwright/federation.spec.ts
// 연합 E2E 사용자 여정: 대시보드 → 인박스 캡처 → 분류 좋아요(확인) → // 연합 E2E: 대시보드 → 인박스 캡처 → 확인 → 작업 등장 → 대시보드 복귀.
// 작업 페이지(개인 여행 — 한국) 등장 → 리스크 레이더 → 대시보드 복귀. // phase-16+: 휴리스틱 제거 → 분류는 실 Ollama. 정확 라벨/지연위험(시드 없음) 대신 '흐름'을 검증.
// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { resetSeed } from "./fixtures/seed-reset"; import { resetSeed } from "./fixtures/seed-reset";
@ -13,46 +12,46 @@ test.beforeEach(async () => {
await resetSeed(); await resetSeed();
}); });
test("연합: 캡처 → 분류 → 확인 → 작업 트리 등장 → 리스크 → 대시보드 집계", async ({ page }) => { test("연합: 캡처 → 확인 → 작업 트리 등장 → 대시보드", async ({ page }) => {
// 0) 루트 → 대시보드 리다이렉트, 상단 내비 13항목 test.setTimeout(150_000); // 실 Ollama 35B 콜드스타트 + 분류 여유
// 0) 루트 → 대시보드 + 내비
await page.goto("/"); await page.goto("/");
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("navigation").getByText("작업")).toBeVisible();
await expect(page.getByRole("navigation").getByText("인박스")).toBeVisible();
// 1) 인박스로 이동 후 캡처 (내비로 스코프 — 카드 CTA 와 구분)
const nav = page.getByRole("navigation"); const nav = page.getByRole("navigation");
await expect(nav.getByText("작업")).toBeVisible();
await expect(nav.getByText("인박스")).toBeVisible();
// 1) 인박스 캡처
await nav.getByRole("link", { name: "인박스" }).click(); await nav.getByRole("link", { name: "인박스" }).click();
await expect(page).toHaveURL(/\/inbox$/); await expect(page).toHaveURL(/\/inbox$/);
const composer = page.getByLabel("인박스에 빠르게 캡처"); const composer = page.getByLabel("인박스에 빠르게 캡처");
await composer.fill(RAW); await composer.fill(RAW);
await composer.press("Enter"); await composer.press("Enter");
// 2) 분류 결과 — 작업 / 개인 여행 — 한국 / 구매(reason) // 2) 실 Ollama 분류(수초) → 확인 버튼
const card = page.locator(".sb-cap.fresh").first(); const card = page.locator(".sb-cap.fresh").first();
await expect(card.getByText("작업", { exact: true })).toBeVisible(); await expect(card.getByRole("button", { name: /좋아요, 그렇게 해줘/ })).toBeVisible({
await expect(card.locator(".r-chip.proj")).toContainText("개인 여행 — 한국"); timeout: 90000,
await expect(card.locator(".sb-reason")).toContainText("작업"); });
// 3) 좋아요(확인=실체화)
await card.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click(); await card.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click();
await expect(card.locator(".sb-done")).toBeVisible();
// 4) 작업 페이지 → 개인 필터 → 여행 — 한국에 새 task 등장(개인도 숨기지 않음)
await nav.getByRole("link", { name: "작업" }).click();
await expect(page).toHaveURL(/\/tasks/);
await page.locator(".tree-row.folder", { hasText: "개인" }).click();
await expect(page.locator(".kcard-title", { hasText: "비행기 티켓" }).first()).toBeVisible();
// 5) 업무 필터 → 리스크 레이더 지연 위험
await page.locator(".tree-row.folder", { hasText: "업무" }).click();
const radar = page.locator(".rradar");
await expect(radar).toContainText("리스크 레이더");
await expect(radar).toContainText("지연 위험");
// 6) 대시보드 복귀 → 요약 갱신 // 3) 연합: 확인 → 작업 실체화. 작업 페이지에 카드 등장(분류에 따라 업무/개인)
await expect
.poll(
async () => {
await page.goto("/tasks");
await page.locator(".tree-row.folder", { hasText: "개인" }).click();
const p = await page.locator(".kcard").count();
await page.locator(".tree-row.folder", { hasText: "업무" }).click();
const w = await page.locator(".kcard").count();
return p + w;
},
{ timeout: 30000 },
)
.toBeGreaterThan(0);
// 4) 대시보드 복귀 → 정상 렌더
await nav.getByRole("link", { name: "대시보드" }).click(); await nav.getByRole("link", { name: "대시보드" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText("아리 브리핑")).toBeVisible(); await expect(page.getByText("아리 브리핑")).toBeVisible();
await expect(page.getByRole("heading", { name: "할 일" })).toBeVisible();
}); });

@ -1,72 +1,53 @@
// frontend/playwright/inbox.spec.ts // frontend/playwright/inbox.spec.ts
// 백엔드를 LLM_PROVIDER=heuristic 로 기동한 상태를 가정(결정성·속도·폴백 배지). // phase-16+: 휴리스틱 제거 → 분류는 실 Ollama(수초·비결정). 정확한 라벨 대신 '흐름'을 검증한다.
// 캡처는 인박스 시드를 변경하므로 단일 워커 직렬.
import AxeBuilder from "@axe-core/playwright"; import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { resetSeed } from "./fixtures/seed-reset"; import { resetSeed } from "./fixtures/seed-reset";
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
// 전체 스위트(직렬)에서 앞선 spec 의 캡처 누적과 무관하게 깨끗한 시드에서 시작.
test.beforeAll(resetSeed); test.beforeAll(resetSeed);
test("캡처 → 분류 → 좋아요 → 작업 페이지에 등장(연합)", async ({ page }) => { test("캡처 → 분류 카드 → 확인 → 작업 등장(연합)", async ({ page }) => {
test.setTimeout(150_000); // 실 Ollama 35B 콜드스타트 + 분류 여유
await page.goto("/inbox"); await page.goto("/inbox");
const input = page.getByLabel("인박스에 빠르게 캡처"); const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill("다음 주에 한국 놀러가는 비행기 티켓 사기"); await input.fill("다음 주에 한국 놀러가는 비행기 티켓 사기");
await input.press("Enter"); await input.press("Enter");
const fresh = page.locator(".sb-cap.fresh").first(); const fresh = page.locator(".sb-cap.fresh").first();
await expect(fresh.getByText("작업", { exact: true })).toBeVisible(); // 실 Ollama 분류(수초) → 확인 버튼 노출
await expect(fresh.locator(".r-chip.proj")).toContainText("개인 여행 — 한국"); await expect(fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ })).toBeVisible({
await expect(fresh.locator(".r-chip.auto")).toContainText("가격 추적 알림 켜둠"); timeout: 90000,
});
await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click(); await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click();
await expect(fresh.locator(".sb-done")).toBeVisible();
// 작업 페이지(개인 스코프)에서 확인 — 연합 (.first(): 전체 스위트에서 중복 캡처 대비) // 연합: 확인 → 작업 실체화. 결과(작업 페이지에 카드 등장)로 검증(card 재렌더 타이밍 무관).
await page.goto("/tasks"); await expect
await page.locator(".tree-row.folder", { hasText: "개인" }).click(); .poll(
await expect( async () => {
page.locator(".kcard-title", { hasText: "다음 주에 한국 놀러가는 비행기 티켓 사기" }).first(), await page.goto("/tasks");
).toBeVisible(); await page.locator(".tree-row.folder", { hasText: "개인" }).click();
const p = await page.locator(".kcard").count();
await page.locator(".tree-row.folder", { hasText: "업무" }).click();
const w = await page.locator(".kcard").count();
return p + w;
},
{ timeout: 30000 },
)
.toBeGreaterThan(0);
}); });
test("다르게 분류 — 타입 순환 task→event", async ({ page }) => { test("다르게 분류 — 재분류 버튼 동작", async ({ page }) => {
test.setTimeout(150_000);
await page.goto("/inbox"); await page.goto("/inbox");
const input = page.getByLabel("인박스에 빠르게 캡처"); const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill("리포트 회신 보내기"); await input.fill("리포트 회신 보내기");
await input.press("Enter"); await input.press("Enter");
const fresh = page.locator(".sb-cap.fresh").first(); const fresh = page.locator(".sb-cap.fresh").first();
await expect(fresh.getByText("작업", { exact: true })).toBeVisible(); await expect(fresh.getByRole("button", { name: /다르게 분류/ })).toBeVisible({ timeout: 90000 });
// 재분류 클릭 → 카드 유지(타입 순환은 LLM 라벨에 의존하므로 정확값은 단언하지 않음)
await fresh.getByRole("button", { name: /다르게 분류/ }).click(); await fresh.getByRole("button", { name: /다르게 분류/ }).click();
await expect(fresh.getByText("일정", { exact: true })).toBeVisible(); await expect(fresh).toBeVisible();
});
const GOLDEN = [
{ raw: "다음 주에 한국 놀러가는 비행기 티켓 사기", type: "작업", proj: "개인 여행 — 한국" },
{ raw: "수요일 11시 자전거 수리 맡기기", type: "일정", proj: "개인 캘린더" },
{ raw: "엄마 생신 선물 미리 알아보기", type: "작업", proj: "가족" },
{ raw: "온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까", type: "아이디어", proj: "아이디어 보드" },
];
for (const g of GOLDEN) {
test(`골든: "${g.raw}" → ${g.type}`, async ({ page }) => {
await page.goto("/inbox");
const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill(g.raw);
await input.press("Enter");
const fresh = page.locator(".sb-cap.fresh").first();
await expect(fresh.getByText(g.type, { exact: true })).toBeVisible();
await expect(fresh.locator(".r-chip.proj")).toContainText(g.proj);
});
}
test("LLM 폴백 — 규칙 기반(오프라인) 배지", async ({ page }) => {
await page.goto("/inbox");
const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill("엄마 생신 선물 미리 알아보기");
await input.press("Enter");
const fresh = page.locator(".sb-cap.fresh").first();
await expect(fresh.getByText("작업", { exact: true })).toBeVisible();
await expect(fresh.getByText("규칙 기반(오프라인)")).toBeVisible();
}); });
test("음성 스텁 — voice 아이콘 행", async ({ page }) => { test("음성 스텁 — voice 아이콘 행", async ({ page }) => {

@ -47,49 +47,13 @@ test("베지어 커넥터: 8개 .jx-line 경로가 렌더된다", async ({ page
await expect(page.locator(".jx-svg")).toHaveAttribute("aria-hidden", "true"); await expect(page.locator(".jx-svg")).toHaveAttribute("aria-hidden", "true");
}); });
test("호버 강조: 카드에 올리면 self+이웃 lit, 나머지 mute, 관련 라인 on", async ({ page }) => { // phase-16+: 작업 시드 제거 → 카드 내용(작업 제목)·done 상태·라이브 동기화는
const report = page.locator(".fcard.task", { hasText: "분기 리포트 초안 마무리" }); // 실제 작업이 있어야 검증 가능(노드 매핑 k1/k3 는 공개 API로 생성 불가) → 해당 테스트 제외.
const brief = page.locator(".fcard", { hasText: "아침 브리핑 확인" }); // m-brief → t-report // 여정의 구조(노드 카드·베지어·테이블 행/별·게이지·반응형)는 아래에서 계속 검증한다.
const unrelated = page.locator(".fcard.task", { hasText: "온보딩 와이어프레임 피드백 정리" });
await report.hover(); test("하단 작업 테이블: 5행 + 별 2개 on(구조)", async ({ page }) => {
await expect(report).toHaveClass(/lit/);
await expect(brief).toHaveClass(/lit/); // 이웃(들어오는 링크)
await expect(unrelated).toHaveClass(/mute/); // 비이웃
// t-report 와 연결된 라인은 .on
await expect(page.locator(".jx-link.on")).toHaveCount(2); // m-brief>t-report, t-report>k-strat
await expect(page.locator(".jx-link.off").first()).toBeVisible();
// 호버 해제 시 강조 초기화
await page.locator(".jx-label").hover();
await expect(report).not.toHaveClass(/lit/);
await expect(page.locator(".jx-link.on")).toHaveCount(0);
});
test("done 토글: 체크 시 카드 done 클래스 + 카운트 감소", async ({ page }) => {
const card = page.locator(".fcard.task", { hasText: "사용자 인터뷰 5건 정리" });
await expect(card).not.toHaveClass(/done/);
// 시작 카운트 3/4 (welcome 만 done) — 컬럼 헤더 카운트 배지(.jx-chead .n)
const count = page.locator(".jx-chead .n", { hasText: "/4" });
await expect(count).toHaveText("3/4");
await card.getByRole("button", { name: "사용자 인터뷰 5건 정리" }).click();
await expect(card).toHaveClass(/done/);
await expect(count).toHaveText("2/4");
// 다시 토글 → 복원
await card.getByRole("button", { name: "사용자 인터뷰 5건 정리" }).click();
await expect(card).not.toHaveClass(/done/);
await expect(count).toHaveText("3/4");
});
test("하단 작업 테이블: 5행 + 상태 칩 + 별 2개 on", async ({ page }) => {
const rows = page.locator(".jtable tbody tr"); const rows = page.locator(".jtable tbody tr");
await expect(rows).toHaveCount(5); await expect(rows).toHaveCount(5); // TABLE_ROWS 고정
await expect(page.locator(".jtable .chip.prog").first()).toBeVisible();
await expect(page.locator(".jtable .chip.review")).toBeVisible();
await expect(page.locator(".jtable .chip.done")).toBeVisible();
await expect(page.locator(".jtable .chip.sched")).toBeVisible();
await expect(page.locator(".jtable .star.on")).toHaveCount(2); await expect(page.locator(".jtable .star.on")).toHaveCount(2);
}); });
@ -111,8 +75,3 @@ test("반응형: 720px 이하에서 베지어 svg 숨김", async ({ page }) => {
await expect(page.locator(".jx-labels")).toBeHidden(); await expect(page.locator(".jx-labels")).toBeHidden();
}); });
test("작업 상태가 라이브 데이터를 반영한다(테이블 ↔ 흐름 일치)", async ({ page }) => {
// t-welcome 은 done(seed) → 흐름카드/테이블 동기화
const tableWelcome = page.locator(".jtable tbody tr", { hasText: "신규 입사자 환영" });
await expect(tableWelcome.locator(".chip.done")).toBeVisible();
});

@ -1,5 +1,6 @@
// frontend/playwright/mail.spec.ts — 메일 E2E (extract→작업 / 회신→결재함) // frontend/playwright/mail.spec.ts — 메일 E2E
// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정. // phase-16+: 메일 mock 시드 제거 → 빈 상태. 실데이터는 Gmail/Outlook 연결 후 유입(OAuth, e2e 범위 밖).
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { resetSeed } from "./fixtures/seed-reset"; import { resetSeed } from "./fixtures/seed-reset";
@ -9,34 +10,23 @@ test.beforeEach(async () => {
await resetSeed(); await resetSeed();
}); });
test("메일 ai 추출 → 작업 페이지 왕복", async ({ page }) => { test("메일 — 연결된 계정 없음(빈 상태) 정상 렌더", async ({ page }) => {
await page.goto("/mail"); await page.goto("/mail");
await page.locator(".mrow", { hasText: "온보딩 시안 v3" }).first().click(); await expect(page.locator(".mailpage")).toBeVisible();
await expect(page.getByText("아리가 분석했어요")).toBeVisible(); await expect(page.locator(".mrow")).toHaveCount(0); // 시드 메일 제거
await page.getByRole("button", { name: "작업", exact: true }).first().click();
await expect(page.getByText("작업에 추가했어요")).toBeVisible(); // 토스트
await page.goto("/tasks");
await expect(page.getByText("온보딩 시안 v3 피드백 정리").first()).toBeVisible();
}); });
test("회신 초안 선택 → 결재함 대기", async ({ page }) => { test("메일 계정 연결 진입점은 설정 메일 탭", async ({ page }) => {
await page.goto("/mail"); await page.goto("/settings?tab=mail");
await page.locator(".mrow", { hasText: "온보딩 시안 v3" }).first().click(); await expect(page.getByRole("button", { name: /계정 추가/ })).toBeVisible();
await page.locator(".rep", { hasText: "금요일까지 피드백 드릴게요" }).click();
await page.locator(".cp-send").click();
await expect(page.getByText("결재함에서 확인하세요")).toBeVisible();
});
test("m7(빈 ai) → 요약만, AI pill 없음", async ({ page }) => {
await page.goto("/mail");
await page.locator(".mrow", { hasText: "ChatGPT Plus" }).first().click();
await expect(page.getByText("아리가 분석했어요")).toBeVisible();
// 빈 ai → 작업/일정 추가 버튼 없음
await expect(page.getByRole("button", { name: "작업", exact: true })).toHaveCount(0);
}); });
test("계정 필터 — 사이드(side) 전환", async ({ page }) => { test("빈 메일 페이지 a11y — 위반 0건", async ({ page }) => {
await page.goto("/mail"); await page.goto("/mail");
await page.getByText("사이드", { exact: false }).first().click(); await expect(page.locator(".mailpage")).toBeVisible();
await expect(page.locator(".mrow", { hasText: "수요 독서모임" }).first()).toBeVisible(); const r = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(["color-contrast"])
.analyze();
expect(r.violations, JSON.stringify(r.violations, null, 2)).toEqual([]);
}); });

@ -9,20 +9,44 @@ async function login(page, email: string) {
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
} }
test("지우는 시드 작업이 보이고, 현우는 빈 상태", async ({ browser }) => { test("A가 만든 작업은 A만 보고 B는 빈 상태(격리)", async ({ browser }) => {
// phase-16+: 작업 시드 제거 → A(지우)가 인박스 캡처→확인으로 작업을 만든다(실 Ollama).
test.setTimeout(150_000);
const ctxA = await browser.newContext(); const ctxA = await browser.newContext();
const ctxB = await browser.newContext(); const ctxB = await browser.newContext();
const pa = await ctxA.newPage(); const pa = await ctxA.newPage();
const pb = await ctxB.newPage(); const pb = await ctxB.newPage();
await login(pa, "jiwoo@lumi.co"); await login(pa, "jiwoo@lumi.co");
await pa.goto("/tasks"); await pa.goto("/inbox");
await expect(pa.getByText("분기 리포트 초안 마무리").first()).toBeVisible(); // 지우 시드 작업 const input = pa.getByLabel("인박스에 빠르게 캡처");
await input.fill("분기 리포트 초안 마무리");
await input.press("Enter");
const fresh = pa.locator(".sb-cap.fresh").first();
await expect(fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ })).toBeVisible({
timeout: 90_000,
});
await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click();
// A 의 작업 페이지엔 작업 ≥1 (확인 반영까지 폴링)
await expect
.poll(
async () => {
await pa.goto("/tasks");
await pa.locator(".tree-row.folder", { hasText: "개인" }).click();
const p = await pa.locator(".kcard").count();
await pa.locator(".tree-row.folder", { hasText: "업무" }).click();
const w = await pa.locator(".kcard").count();
return p + w;
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
// B(현우)는 소유 작업 0 → 빈 보드(격리)
await login(pb, "hyunwoo@lumi.co"); await login(pb, "hyunwoo@lumi.co");
await pb.goto("/tasks"); await pb.goto("/tasks");
// 현우는 소유 작업 0 → 시드 작업(분기 리포트) 안 보임 await expect(pb.locator(".kcard")).toHaveCount(0);
await expect(pb.getByText("분기 리포트 초안 마무리")).toHaveCount(0);
await ctxA.close(); await ctxA.close();
await ctxB.close(); await ctxB.close();

@ -19,18 +19,22 @@ test("6개 탭이 보이고 기본은 계정 탭", async ({ page }) => {
await expect(page.getByRole("heading", { level: 2, name: "계정" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "계정" })).toBeVisible();
}); });
test("메일 탭 — 3개 계정 카드 + 3/3 연결됨", async ({ page }) => { test("메일 탭 — 연결된 계정 없음(빈 상태)", async ({ page }) => {
// phase-16+: 메일 mock 제거 → 빈 상태. Gmail/Outlook 을 '계정 추가'로 연결.
await page.getByRole("button", { name: /^메일/ }).click(); await page.getByRole("button", { name: /^메일/ }).click();
await expect(page.getByRole("heading", { level: 2, name: "메일 계정" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "메일 계정" })).toBeVisible();
await expect(page.locator(".cn-grid .cn-card")).toHaveCount(3); await expect(page.locator(".cn-grid .cn-card")).toHaveCount(0);
await expect(page.locator(".cn-count")).toHaveText("3/3 연결됨"); await expect(page.getByText(/연결된 메일 계정이 없어요/)).toBeVisible();
}); });
test("메일 동기화 → 토스트", async ({ page }) => { test("메일 탭 — 계정 추가 메뉴(Gmail·Outlook, 미구성 비활성)", async ({ page }) => {
await page.getByRole("button", { name: /^메일/ }).click(); await page.getByRole("button", { name: /^메일/ }).click();
const work = page.locator(".cn-card", { hasText: "회사" }).first(); await page.getByRole("button", { name: /계정 추가/ }).click();
await work.getByRole("button", { name: /동기화/ }).click(); const menu = page.getByRole("menu", { name: /추가할 계정 제공자/ });
await expect(page.locator(".au-toast")).toContainText("새 항목"); await expect(menu.getByRole("menuitem", { name: /Gmail/ })).toBeVisible();
// 자격증명 미설정 환경 → 두 provider 모두 비활성(관리자 설정 필요)
await expect(menu.getByRole("menuitem", { name: /Gmail/ })).toBeDisabled();
await expect(menu.getByRole("menuitem", { name: /Outlook/ })).toBeDisabled();
}); });
test("계정 탭 — 이름/역할 저장", async ({ page }) => { test("계정 탭 — 이름/역할 저장", async ({ page }) => {

@ -6,9 +6,9 @@ test("/ → /dashboard 리다이렉트", async ({ page }) => {
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
}); });
test("12개 내비 항목이 모든 페이지에서 보인다", async ({ page }) => { test("내비 항목이 모든 페이지에서 보인다", async ({ page }) => {
await page.goto("/tasks"); await page.goto("/tasks");
for (const label of ["대시보드", "인박스", "결재함", "작업", "라이프"]) { for (const label of ["대시보드", "인박스", "결재함", "작업", "메일"]) {
await expect(page.getByRole("link", { name: new RegExp(label) })).toBeVisible(); await expect(page.getByRole("link", { name: new RegExp(label) })).toBeVisible();
} }
}); });

@ -1,68 +1,29 @@
// frontend/playwright/tasks.spec.ts // frontend/playwright/tasks.spec.ts
// 주의: 백엔드(:31800) 시드 DB 를 변경하는 테스트 포함 → 단일 워커 직렬 실행. // phase-16+: 작업 mock 시드 제거 → 빈 칸반에서 시작. UI 로 만들 수 있는 흐름만 검증.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { resetSeed } from "./fixtures/seed-reset"; import { resetSeed } from "./fixtures/seed-reset";
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
// 깨끗한 시드에서 시작(앞선 spec 의 변경과 격리). test.beforeEach(resetSeed);
test.beforeAll(resetSeed);
test("초기 진입: 업무 스코프 + 칸반 5컬럼 + 리스크 레이더", async ({ page }) => { test("초기 진입: 업무 스코프 + 칸반 5컬럼(빈) + 리스크 레이더", async ({ page }) => {
await page.goto("/tasks"); await page.goto("/tasks");
await expect(page.locator(".tree-row.folder.on .tree-name")).toHaveText("업무"); await expect(page.locator(".tree-row.folder.on .tree-name")).toHaveText("업무");
await expect(page.locator(".kboard .kcol")).toHaveCount(5); await expect(page.locator(".kboard .kcol")).toHaveCount(5);
await expect(page.locator(".rradar")).toBeVisible(); await expect(page.locator(".kcard")).toHaveCount(0); // 작업 시드 제거 → 빈 보드
await expect(page.locator(".rr-ht b")).toHaveText("리스크 레이더"); // 리스크 레이더는 위험이 있을 때만 렌더(빈 보드 → 없음)
}); await expect(page.locator(".rradar")).toHaveCount(0);
test("폴더 펼침 → 프로젝트 필터 + 브레드크럼", async ({ page }) => {
await page.goto("/tasks");
await page.locator(".tree-row", { hasText: "분기 리포트" }).first().click();
await expect(page.locator(".kcard-title", { hasText: "분기 리포트 초안 마무리" })).toBeVisible();
await expect(page.locator(".kcard-title", { hasText: "사용자 인터뷰 5건 정리" })).toHaveCount(0);
await expect(page.locator(".crumbs .cur")).toContainText("분기 리포트");
}); });
test("뷰 전환 칸반 ↔ 리스트", async ({ page }) => { test("뷰 전환 칸반 ↔ 리스트", async ({ page }) => {
await page.goto("/tasks"); await page.goto("/tasks");
await page.locator(".view-seg button", { hasText: "리스트" }).click(); await page.locator(".view-seg button", { hasText: "리스트" }).click();
await expect(page.locator(".lgroups")).toBeVisible(); await expect(page.locator(".lgroups")).toBeVisible();
await expect(page.locator(".lgroup").first()).toBeVisible();
await page.locator(".view-seg button", { hasText: "칸반" }).click(); await page.locator(".view-seg button", { hasText: "칸반" }).click();
await expect(page.locator(".kboard")).toBeVisible(); await expect(page.locator(".kboard")).toBeVisible();
}); });
test("리스크 레이더 CTA → 작업 드로어 열기", async ({ page }) => {
await page.goto("/tasks");
await page.locator(".rr-item.t-coral .rr-cta", { hasText: "작업 열기" }).click();
await expect(page.locator(".dpanel .dp-title")).toContainText("분기 리포트 초안 마무리");
});
test("드로어: 하위작업 드릴다운 → 상위 복귀 → 댓글", async ({ page }) => {
await page.goto("/tasks");
await page.locator(".kcard", { hasText: "분기 리포트 초안 마무리" }).click();
await expect(page.locator(".dpanel")).toBeVisible();
await page.locator(".subrow", { hasText: "매출 섹션 작성" }).click();
await expect(page.locator(".dp-crumbs .cz.cur")).toHaveText("매출 섹션 작성");
await page.locator(".dp-crumbs button.cz", { hasText: "분기 리포트 초안 마무리" }).click();
await page.locator(".cmt-compose input").fill("진행 상황 공유드립니다");
await page.locator(".cmt-compose input").press("Enter");
await expect(page.locator(".cmt-text", { hasText: "진행 상황 공유드립니다" })).toBeVisible();
});
test("Auto-Scaffolding 미리보기 → 적용", async ({ page }) => {
await page.goto("/tasks");
await page.locator(".kcard", { hasText: "OKR 중간 점검 자료 준비" }).click();
await page.locator(".scaffold-trigger", { hasText: "아리에게 업무 쪼개기 맡기기" }).click();
await expect(page.locator(".scaffold-panel .sp-item").first()).toBeVisible();
const n = await page.locator(".scaffold-panel .sp-item").count();
expect(n).toBeGreaterThanOrEqual(4);
await page.locator(".sp-apply").click();
// 기존 하위 2개 + 신규 n개
await expect(page.locator(".dpanel .subrow")).toHaveCount(n + 2);
});
test("새 프로젝트 생성 → 서버 영속(새로고침 유지)", async ({ page }) => { test("새 프로젝트 생성 → 서버 영속(새로고침 유지)", async ({ page }) => {
await page.goto("/tasks"); await page.goto("/tasks");
await page.locator(".tree-add", { hasText: "새 프로젝트" }).first().click(); await page.locator(".tree-add", { hasText: "새 프로젝트" }).first().click();
@ -72,32 +33,3 @@ test("새 프로젝트 생성 → 서버 영속(새로고침 유지)", async ({
await page.reload(); await page.reload();
await expect(page.locator(".tree-name", { hasText: "신규 프로젝트 E2E" })).toBeVisible(); await expect(page.locator(".tree-name", { hasText: "신규 프로젝트 E2E" })).toBeVisible();
}); });
test("작업 상태 이동(드로어 상태 메뉴) → 영속", async ({ page }) => {
await page.goto("/tasks");
await page.locator(".kcard", { hasText: "스프린트 회고 문서 배포" }).click();
await page.locator(".status-pill").click();
await page.locator(".status-menu button", { hasText: "완료" }).click();
await page.locator(".dp-close").click();
const doneCol = page.locator(".kcol", { hasText: "완료" });
await expect(doneCol.locator(".kcard", { hasText: "스프린트 회고 문서 배포" })).toBeVisible();
await page.reload();
await expect(
page.locator(".kcol", { hasText: "완료" }).locator(".kcard", { hasText: "스프린트 회고 문서 배포" }),
).toBeVisible();
});
test("즐겨찾기: 핀 토글 → 즐겨찾기 스코프 필터", async ({ page }) => {
await page.goto("/tasks");
const wireRow = page.locator(".tree-row", { hasText: "와이어프레임" }).first();
await wireRow.hover();
await wireRow.locator(".tree-pin").click();
const favFolder = page.locator(".tree-row.folder", { hasText: "즐겨찾기" });
// 즐겨찾기 배지 1로 증가
await expect(favFolder.locator(".tree-badge")).toHaveText("1");
await favFolder.click();
// 즐겨찾기 스코프 → onb-wire(와이어프레임) 작업만 메인에 표시
await expect(
page.locator(".kcard-title", { hasText: "온보딩 와이어프레임 피드백 정리" }),
).toBeVisible();
});

@ -5,6 +5,22 @@ import { toHaveNoViolations } from "vitest-axe/dist/matchers";
expect.extend({ toHaveNoViolations }); expect.extend({ toHaveNoViolations });
// next/navigation 전역 목 — 라우팅 의존 컴포넌트(Mail/Tasks/Calendar Client)가
// useParams/useRouter 를 쓰므로, 라우터 컨텍스트 없는 단위 렌더에서도 동작하게 한다.
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
useParams: () => ({}),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
// jsdom 미구현 API 폴리필 (ResizeObserver — 여정 베지어 보드가 사용) // jsdom 미구현 API 폴리필 (ResizeObserver — 여정 베지어 보드가 사용)
if (!("ResizeObserver" in globalThis)) { if (!("ResizeObserver" in globalThis)) {
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {

Loading…
Cancel
Save