diff --git a/backend/tests/_factories.py b/backend/tests/_factories.py new file mode 100644 index 0000000..6b73489 --- /dev/null +++ b/backend/tests/_factories.py @@ -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 diff --git a/backend/tests/_fake_llm.py b/backend/tests/_fake_llm.py new file mode 100644 index 0000000..fc0f2be --- /dev/null +++ b/backend/tests/_fake_llm.py @@ -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 "아직 행동이 정해지지 않아 아이디어 보드에 보관했어요." diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fc475c9..f1156ab 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,20 +1,14 @@ # backend/tests/conftest.py -import os - -# 테스트는 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 +import pytest +from fastapi.testclient import TestClient from sqlmodel import Session, SQLModel, create_engine from sqlmodel.pool import StaticPool from app import seed as seed_mod from app.db import get_session -from app.llm.heuristic import HeuristicProvider from app.llm.provider import get_provider from app.main import app +from tests._fake_llm import FakeLLMProvider @pytest.fixture(autouse=True) @@ -27,6 +21,27 @@ def _clear_llm_overlay(): 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() def session(): engine = create_engine( @@ -50,7 +65,7 @@ def client(session): app.dependency_overrides[get_session] = _get_session # LLM은 기본적으로 heuristic 강제(테스트 결정성). - app.dependency_overrides[get_provider] = lambda: HeuristicProvider() + app.dependency_overrides[get_provider] = lambda: FakeLLMProvider() yield TestClient(app) app.dependency_overrides.clear() @@ -72,7 +87,7 @@ def client_auth(session, monkeypatch): yield ss app.dependency_overrides[get_session] = _get_session - app.dependency_overrides[get_provider] = lambda: HeuristicProvider() + app.dependency_overrides[get_provider] = lambda: FakeLLMProvider() yield TestClient(app) app.dependency_overrides.clear() get_settings.cache_clear() diff --git a/backend/tests/test_api_journey.py b/backend/tests/test_api_journey.py index 8d3052c..e0028ef 100644 --- a/backend/tests/test_api_journey.py +++ b/backend/tests/test_api_journey.py @@ -1,4 +1,8 @@ # backend/tests/test_api_journey.py +# phase-16+: 작업 시드 제거 → 여정 카드가 참조하는 작업(k1/k3)은 테스트가 직접 생성. +from tests._factories import make_task + + def test_journey_shape(client): j = client.get("/api/journey").json() 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() report = next(c for c in j["cards"]["s2"] if c["node"] == "t-report") 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 -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"}) j = client.get("/api/journey").json() report = next(c for c in j["cards"]["s2"] if c["node"] == "t-report") diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index c36da9c..fc3bf0a 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -5,7 +5,8 @@ import zipfile 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 z = zipfile.ZipFile(io.BytesIO(raw)) names = set(z.namelist()) diff --git a/backend/tests/test_federation.py b/backend/tests/test_federation.py index b1e42cb..87870d5 100644 --- a/backend/tests/test_federation.py +++ b/backend/tests/test_federation.py @@ -1,10 +1,22 @@ """연합 시나리오 통합 테스트 — 캡처→분류→확인→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 = "다음 주에 한국 놀러가는 비행기 티켓 사기" +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): out = [] for n in nodes: @@ -65,8 +77,10 @@ def test_confirm_idempotent(client): 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)""" + s, _ = session + _seed_risk(s) risks = client.get("/api/risks", params={"area": "work"}).json() assert len(risks) <= 3 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" -def test_risk_disappears_when_done(client): +def test_risk_disappears_when_done(client, session): """k1(분기 리포트)을 done 으로 PATCH 후 → 지연 위험에서 k1 사라짐(재계산)""" + s, _ = session + _seed_risk(s) before = client.get("/api/risks", params={"area": "work"}).json() delay_before = next((r for r in before if r["kind"] == "지연 위험"), None) assert delay_before and delay_before["task_id"] == "k1" diff --git a/backend/tests/test_federation_real.py b/backend/tests/test_federation_real.py index d312d72..1c5c476 100644 --- a/backend/tests/test_federation_real.py +++ b/backend/tests/test_federation_real.py @@ -4,7 +4,19 @@ from app import seed as seedmod from app.automation.event_bus import bus from app.connectors.base import RawRecord 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): @@ -40,7 +52,7 @@ _MSG = { def test_real_sync_publishes_mail_received_once(session): s, _ = session 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"]) 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): s, _ = session seedmod.run_seed(session=s, reset=True) - acct = s.get(ConnectorAccount, "ca-mail-personal") + acct = _mail_account(s) class BoomConnector(GmailConnector): def fetch(self, session, *, full=False): diff --git a/backend/tests/test_multiuser_isolation.py b/backend/tests/test_multiuser_isolation.py index 37dfaa4..5e56a30 100644 --- a/backend/tests/test_multiuser_isolation.py +++ b/backend/tests/test_multiuser_isolation.py @@ -36,23 +36,22 @@ def test_create_owner_is_current_user_not_body(client_auth): def test_b_starts_empty_a_full(client_auth): - # 시드 데이터는 전부 지우 소유 → 현우는 빈 상태에서 시작 + # phase-16+: 작업 시드 제거 → A(지우)가 작업을 만들면 A 만 보이고 B(현우)는 빈 상태 _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") _login(client_auth, "hyunwoo@lumi.co") assert client_auth.get("/api/tasks").json() == [] assert client_auth.get("/api/inbox").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() assert notif["now"] == [] and notif["later"] == [] and notif["held"] == [] @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): _login(client_auth, "hyunwoo@lumi.co") diff --git a/backend/tests/test_nl_parser.py b/backend/tests/test_nl_parser.py index 3e56002..cf85ab9 100644 --- a/backend/tests/test_nl_parser.py +++ b/backend/tests/test_nl_parser.py @@ -2,7 +2,7 @@ import pytest from app.automation.nl_parser import parse_rule -from app.llm.heuristic import HeuristicProvider +from tests._fake_llm import FakeLLMProvider GOLDEN = [ ( @@ -39,7 +39,7 @@ def test_golden_examples(text, cat, trig, cond, action): def test_heuristic_fallback_classifies_cat(): # examples 미일치 → 휴리스틱(명시 provider 로 환경(Ollama 가동 여부) 무관 결정적) - r = parse_rule("영수증 들어오면 자동으로 정리해줘", provider=HeuristicProvider()) + r = parse_rule("영수증 들어오면 자동으로 정리해줘", provider=FakeLLMProvider()) assert r.matched and r.cat == "life" and r.model == "heuristic" diff --git a/backend/tests/test_phase18_features.py b/backend/tests/test_phase18_features.py new file mode 100644 index 0000000..d31ba07 --- /dev/null +++ b/backend/tests/test_phase18_features.py @@ -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 diff --git a/backend/tests/test_risk.py b/backend/tests/test_risk.py index e01181a..0a22f1a 100644 --- a/backend/tests/test_risk.py +++ b/backend/tests/test_risk.py @@ -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() kinds = [x["kind"] for x in r] assert "지연 위험" in kinds @@ -6,20 +22,25 @@ def test_risk_delay_present(client): 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() dep = [x for x in r if x["kind"] == "의존성"] assert dep, "예산 섹션 작성→경영진 검토 요청 메일 의존성 1건" 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() assert len(r) <= 3 -def test_risk_overload_threshold(client): - # 시드 기준 업무 쏠림이 topN>=avg*1.5 && topN>=4 를 만족하면 포함 +def test_risk_overload_threshold(client, session): + s, _ = session + _seed(s) r = client.get("/api/risks?area=work").json() over = [x for x in r if x["kind"] == "업무 쏠림"] for o in over: diff --git a/backend/tests/test_risks.py b/backend/tests/test_risks.py index 3f1d942..3bd74b6 100644 --- a/backend/tests/test_risks.py +++ b/backend/tests/test_risks.py @@ -1,6 +1,22 @@ -# phase-3 §7.2 — 리스크 정확성 (시드 기준) -def test_risk_delay_top1(client): - # k1(분기 리포트 초안 마무리, due 6/8, doing) → 지연 위험 1건 +# backend/tests/test_risks.py — 리스크 정확성 (phase-16+: 작업 시드 제거 → 테스트가 직접 생성) +from datetime import date + +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() delay = [x for x in r if x["kind"] == "지연 위험"] assert len(delay) == 1 @@ -10,8 +26,9 @@ def test_risk_delay_top1(client): assert delay[0]["cta"] == "작업 열기" -def test_risk_dependency(client): - # 예산 섹션 작성(미완) → 경영진 검토 요청 메일(미완) : violet, cta="후속 작업 보기" +def test_risk_dependency(client, session): + s, _ = session + _seed_risk_tasks(s) r = client.get("/api/risks?area=work").json() dep = [x for x in r if x["kind"] == "의존성"] assert len(dep) == 1 and dep[0]["tone"] == "violet" @@ -19,7 +36,9 @@ def test_risk_dependency(client): 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() over = [x for x in r if x["kind"] == "업무 쏠림"] 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 -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 diff --git a/backend/tests/test_seed_research_trip.py b/backend/tests/test_seed_research_trip.py deleted file mode 100644 index dff11db..0000000 --- a/backend/tests/test_seed_research_trip.py +++ /dev/null @@ -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 diff --git a/dev/phase-16-real-mail.md b/dev/phase-16-real-mail.md new file mode 100644 index 0000000..28154bc --- /dev/null +++ b/dev/phase-16-real-mail.md @@ -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. → 프로젝트 생성(예: `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. → **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= + +# 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 검수, 멀티유저. diff --git a/frontend/playwright/a11y.spec.ts b/frontend/playwright/a11y.spec.ts index 1362c16..3a46aca 100644 --- a/frontend/playwright/a11y.spec.ts +++ b/frontend/playwright/a11y.spec.ts @@ -9,15 +9,12 @@ const AXE = (page: import("@playwright/test").Page) => const PAGES: [string, string][] = [ ["/dashboard", "아리 브리핑"], ["/inbox", "스마트 인박스"], - ["/tasks", "리스크 레이더"], + ["/tasks", "칸반"], // phase-16+: 빈 보드엔 리스크 레이더 없음 → 항상 있는 뷰 토글로 마커 ["/approvals", "아리 결재함"], ["/automation", "내 규칙"], ["/calendar", "내 캘린더"], ["/mail", "받은편지함"], ["/notifications", "트리아지"], - ["/research", "새 조사"], - ["/trip", "다가오는 출장"], - ["/life", "연결된 데이터"], ["/journey", "오늘의 여정"], ]; diff --git a/frontend/playwright/calendar.spec.ts b/frontend/playwright/calendar.spec.ts index f008d03..7e7a387 100644 --- a/frontend/playwright/calendar.spec.ts +++ b/frontend/playwright/calendar.spec.ts @@ -1,5 +1,5 @@ -// frontend/playwright/calendar.spec.ts — 일정/회의 E2E (액션→작업 왕복 + a11y) -// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정. +// frontend/playwright/calendar.spec.ts — 일정 E2E +// phase-16+: 일정 mock 시드 제거 → 빈 상태. 실데이터는 Google 캘린더 연결 후 유입(OAuth, e2e 범위 밖). import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import { resetSeed } from "./fixtures/seed-reset"; @@ -10,54 +10,25 @@ test.beforeEach(async () => { await resetSeed(); }); -test("주 뷰 기본 + 일 뷰 전환 + 회의 진입 버튼", async ({ page }) => { +test("주 뷰 기본 + 일 뷰 전환(빈 일정)", async ({ page }) => { await page.goto("/calendar"); await expect(page.getByRole("heading", { name: "6월 7일 – 13일" })).toBeVisible(); 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 }) => { - await page.goto("/calendar"); - await page.getByRole("tab", { name: "일" }).click(); - 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("일정 계정 연결 진입점은 설정 연결 탭", async ({ page }) => { + await page.goto("/settings?tab=connect"); + await expect(page.getByRole("button", { name: /계정 추가/ })).toBeVisible(); }); -test("사전 브리핑 드로어 a11y (axe)", async ({ page }) => { +test("빈 일정 페이지 a11y — 위반 0건", async ({ page }) => { await page.goto("/calendar"); - await page.getByRole("tab", { name: "일" }).click(); - await page.getByRole("button", { name: /사전 브리핑 보기/ }).first().click(); - await expect(page.getByRole("dialog")).toBeVisible(); - const results = await new AxeBuilder({ page }) + await expect(page.locator(".calpage")).toBeVisible(); + const r = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) .disableRules(["color-contrast"]) - .include('[role="dialog"]') .analyze(); - expect(results.violations, JSON.stringify(results.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); + expect(r.violations, JSON.stringify(r.violations, null, 2)).toEqual([]); }); diff --git a/frontend/playwright/connectors.spec.ts b/frontend/playwright/connectors.spec.ts index 22086f7..807713c 100644 --- a/frontend/playwright/connectors.spec.ts +++ b/frontend/playwright/connectors.spec.ts @@ -9,15 +9,15 @@ test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ page }) => { await resetSeed(); - await page.goto("/life"); - // 라이프 모듈 레일에서 '연결' 모듈로 전환 - await page.locator(".subnav .mod-row", { hasText: "외부 데이터 소스" }).click(); + // 설정 페이지의 '연결' 탭에서 연결 관리 (라이프 페이지 제거됨) + await page.goto("/settings?tab=connect"); await expect(page.getByRole("heading", { name: "연결된 데이터 소스" })).toBeVisible(); }); -test("14개 커넥터 카드 + 12/14 연결됨 + 미연결 카드 표시", async ({ page }) => { - await expect(page.locator(".cn-grid .cn-card")).toHaveCount(14); - await expect(page.locator(".cn-count")).toHaveText("12/14 연결됨"); +test("10개 커넥터 카드 + 8/10 연결됨 + 미연결 카드 표시", async ({ page }) => { + // phase-16+: 메일/일정 mock 제거 → 금융·건강·지식 10개만 + await expect(page.locator(".cn-grid .cn-card")).toHaveCount(10); + await expect(page.locator(".cn-count")).toHaveText("8/10 연결됨"); // Google Fit / KB증권 은 미연결(off + '연결 안 됨') const fit = page.locator(".cn-card", { hasText: "Google Fit" }); @@ -31,8 +31,8 @@ test("14개 커넥터 카드 + 12/14 연결됨 + 미연결 카드 표시", async }); test("동기화 → 토스트 노출", async ({ page }) => { - const work = page.locator(".cn-card", { hasText: "회사" }).first(); - await work.getByRole("button", { name: /동기화/ }).click(); + const woori = page.locator(".cn-card", { hasText: "우리카드" }).first(); + await woori.getByRole("button", { name: /동기화/ }).click(); await expect(page.locator(".au-toast")).toContainText("새 항목"); }); diff --git a/frontend/playwright/dashboard.spec.ts b/frontend/playwright/dashboard.spec.ts index fed5250..bdfb67e 100644 --- a/frontend/playwright/dashboard.spec.ts +++ b/frontend/playwright/dashboard.spec.ts @@ -32,19 +32,19 @@ test("결재함 카드 CTA → /approvals 실제 페이지(phase-7)", async ({ p await expect(page.getByRole("heading", { level: 1, name: /아리 결재함/ })).toBeVisible(); }); -test("작업 요약 항목 클릭 → /tasks?task= 딥링크", async ({ page }) => { - await page.goto("/dashboard"); - await page.locator(".task-title", { hasText: "분기 리포트 초안 마무리" }).click(); - await expect(page).toHaveURL(/\/tasks\?task=/); -}); +// phase-16+: 작업 시드 제거 → 작업 요약 딥링크 테스트는 빈 상태라 제외. test("자연어 명령 입력 → 캡처 후 /inbox 이동", async ({ page }) => { + test.setTimeout(150_000); // 실 Ollama 콜드스타트 여유 await page.goto("/dashboard"); const input = page.getByLabel("아리에게 명령 입력"); await input.fill("수요일 11시 자전거 수리 맡기기"); await page.getByLabel("보내기").click(); - await expect(page).toHaveURL(/\/inbox$/); - await expect(page.locator(".sb-raw", { hasText: "수요일 11시 자전거 수리 맡기기" }).first()).toBeVisible(); + // 실 Ollama 분류(수초) 후 이동 → 넉넉한 타임아웃 + await expect(page).toHaveURL(/\/inbox$/, { timeout: 30000 }); + await expect( + page.locator(".sb-raw", { hasText: "수요일 11시 자전거 수리 맡기기" }).first(), + ).toBeVisible({ timeout: 30000 }); }); test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => { diff --git a/frontend/playwright/federation.spec.ts b/frontend/playwright/federation.spec.ts index 330635f..c98147b 100644 --- a/frontend/playwright/federation.spec.ts +++ b/frontend/playwright/federation.spec.ts @@ -1,7 +1,6 @@ // frontend/playwright/federation.spec.ts -// 연합 E2E 사용자 여정: 대시보드 → 인박스 캡처 → 분류 좋아요(확인) → -// 작업 페이지(개인 › 여행 — 한국) 등장 → 리스크 레이더 → 대시보드 복귀. -// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정. +// 연합 E2E: 대시보드 → 인박스 캡처 → 확인 → 작업 등장 → 대시보드 복귀. +// phase-16+: 휴리스틱 제거 → 분류는 실 Ollama. 정확 라벨/지연위험(시드 없음) 대신 '흐름'을 검증. import { expect, test } from "@playwright/test"; import { resetSeed } from "./fixtures/seed-reset"; @@ -13,46 +12,46 @@ test.beforeEach(async () => { await resetSeed(); }); -test("연합: 캡처 → 분류 → 확인 → 작업 트리 등장 → 리스크 → 대시보드 집계", async ({ page }) => { - // 0) 루트 → 대시보드 리다이렉트, 상단 내비 13항목 +test("연합: 캡처 → 확인 → 작업 트리 등장 → 대시보드", async ({ page }) => { + test.setTimeout(150_000); // 실 Ollama 35B 콜드스타트 + 분류 여유 + // 0) 루트 → 대시보드 + 내비 await page.goto("/"); 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"); + await expect(nav.getByText("작업")).toBeVisible(); + await expect(nav.getByText("인박스")).toBeVisible(); + + // 1) 인박스 캡처 await nav.getByRole("link", { name: "인박스" }).click(); await expect(page).toHaveURL(/\/inbox$/); const composer = page.getByLabel("인박스에 빠르게 캡처"); await composer.fill(RAW); await composer.press("Enter"); - // 2) 분류 결과 — 작업 / 개인 › 여행 — 한국 / 구매(reason) + // 2) 실 Ollama 분류(수초) → 확인 버튼 const card = page.locator(".sb-cap.fresh").first(); - await expect(card.getByText("작업", { exact: true })).toBeVisible(); - await expect(card.locator(".r-chip.proj")).toContainText("개인 › 여행 — 한국"); - await expect(card.locator(".sb-reason")).toContainText("작업"); - - // 3) 좋아요(확인=실체화) + await expect(card.getByRole("button", { name: /좋아요, 그렇게 해줘/ })).toBeVisible({ + timeout: 90000, + }); 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 expect(page).toHaveURL(/\/dashboard$/); await expect(page.getByText("아리 브리핑")).toBeVisible(); - await expect(page.getByRole("heading", { name: "할 일" })).toBeVisible(); }); diff --git a/frontend/playwright/inbox.spec.ts b/frontend/playwright/inbox.spec.ts index 533e008..8014cee 100644 --- a/frontend/playwright/inbox.spec.ts +++ b/frontend/playwright/inbox.spec.ts @@ -1,72 +1,53 @@ // frontend/playwright/inbox.spec.ts -// 백엔드를 LLM_PROVIDER=heuristic 로 기동한 상태를 가정(결정성·속도·폴백 배지). -// 캡처는 인박스 시드를 변경하므로 단일 워커 직렬. +// phase-16+: 휴리스틱 제거 → 분류는 실 Ollama(수초·비결정). 정확한 라벨 대신 '흐름'을 검증한다. import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import { resetSeed } from "./fixtures/seed-reset"; test.describe.configure({ mode: "serial" }); -// 전체 스위트(직렬)에서 앞선 spec 의 캡처 누적과 무관하게 깨끗한 시드에서 시작. test.beforeAll(resetSeed); -test("캡처 → 분류 → 좋아요 → 작업 페이지에 등장(연합)", async ({ page }) => { +test("캡처 → 분류 카드 → 확인 → 작업 등장(연합)", async ({ page }) => { + test.setTimeout(150_000); // 실 Ollama 35B 콜드스타트 + 분류 여유 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.locator(".r-chip.proj")).toContainText("개인 › 여행 — 한국"); - await expect(fresh.locator(".r-chip.auto")).toContainText("가격 추적 알림 켜둠"); + // 실 Ollama 분류(수초) → 확인 버튼 노출 + await expect(fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ })).toBeVisible({ + timeout: 90000, + }); await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click(); - await expect(fresh.locator(".sb-done")).toBeVisible(); - // 작업 페이지(개인 스코프)에서 확인 — 연합 (.first(): 전체 스위트에서 중복 캡처 대비) - await page.goto("/tasks"); - await page.locator(".tree-row.folder", { hasText: "개인" }).click(); - await expect( - page.locator(".kcard-title", { hasText: "다음 주에 한국 놀러가는 비행기 티켓 사기" }).first(), - ).toBeVisible(); + // 연합: 확인 → 작업 실체화. 결과(작업 페이지에 카드 등장)로 검증(card 재렌더 타이밍 무관). + 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); }); -test("다르게 분류 — 타입 순환 task→event", async ({ page }) => { +test("다르게 분류 — 재분류 버튼 동작", async ({ page }) => { + test.setTimeout(150_000); 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.getByRole("button", { name: /다르게 분류/ })).toBeVisible({ timeout: 90000 }); + // 재분류 클릭 → 카드 유지(타입 순환은 LLM 라벨에 의존하므로 정확값은 단언하지 않음) await fresh.getByRole("button", { name: /다르게 분류/ }).click(); - await expect(fresh.getByText("일정", { exact: true })).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(); + await expect(fresh).toBeVisible(); }); test("음성 스텁 — voice 아이콘 행", async ({ page }) => { diff --git a/frontend/playwright/journey.spec.ts b/frontend/playwright/journey.spec.ts index bedc936..8b05790 100644 --- a/frontend/playwright/journey.spec.ts +++ b/frontend/playwright/journey.spec.ts @@ -47,49 +47,13 @@ test("베지어 커넥터: 8개 .jx-line 경로가 렌더된다", async ({ page await expect(page.locator(".jx-svg")).toHaveAttribute("aria-hidden", "true"); }); -test("호버 강조: 카드에 올리면 self+이웃 lit, 나머지 mute, 관련 라인 on", async ({ page }) => { - const report = page.locator(".fcard.task", { hasText: "분기 리포트 초안 마무리" }); - const brief = page.locator(".fcard", { hasText: "아침 브리핑 확인" }); // m-brief → t-report - const unrelated = page.locator(".fcard.task", { hasText: "온보딩 와이어프레임 피드백 정리" }); +// phase-16+: 작업 시드 제거 → 카드 내용(작업 제목)·done 상태·라이브 동기화는 +// 실제 작업이 있어야 검증 가능(노드 매핑 k1/k3 는 공개 API로 생성 불가) → 해당 테스트 제외. +// 여정의 구조(노드 카드·베지어·테이블 행/별·게이지·반응형)는 아래에서 계속 검증한다. - await report.hover(); - 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 }) => { +test("하단 작업 테이블: 5행 + 별 2개 on(구조)", async ({ page }) => { const rows = page.locator(".jtable tbody tr"); - await expect(rows).toHaveCount(5); - 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(rows).toHaveCount(5); // TABLE_ROWS 고정 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(); }); -test("작업 상태가 라이브 데이터를 반영한다(테이블 ↔ 흐름 일치)", async ({ page }) => { - // t-welcome 은 done(seed) → 흐름카드/테이블 동기화 - const tableWelcome = page.locator(".jtable tbody tr", { hasText: "신규 입사자 환영" }); - await expect(tableWelcome.locator(".chip.done")).toBeVisible(); -}); diff --git a/frontend/playwright/mail.spec.ts b/frontend/playwright/mail.spec.ts index 6111211..2237c93 100644 --- a/frontend/playwright/mail.spec.ts +++ b/frontend/playwright/mail.spec.ts @@ -1,5 +1,6 @@ -// frontend/playwright/mail.spec.ts — 메일 E2E (extract→작업 / 회신→결재함) -// 백엔드는 ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic 로 기동 가정. +// frontend/playwright/mail.spec.ts — 메일 E2E +// phase-16+: 메일 mock 시드 제거 → 빈 상태. 실데이터는 Gmail/Outlook 연결 후 유입(OAuth, e2e 범위 밖). +import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import { resetSeed } from "./fixtures/seed-reset"; @@ -9,34 +10,23 @@ test.beforeEach(async () => { await resetSeed(); }); -test("메일 ai 추출 → 작업 페이지 왕복", async ({ page }) => { +test("메일 — 연결된 계정 없음(빈 상태) 정상 렌더", async ({ page }) => { await page.goto("/mail"); - await page.locator(".mrow", { hasText: "온보딩 시안 v3" }).first().click(); - await expect(page.getByText("아리가 분석했어요")).toBeVisible(); - 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(); + await expect(page.locator(".mailpage")).toBeVisible(); + await expect(page.locator(".mrow")).toHaveCount(0); // 시드 메일 제거 }); -test("회신 초안 선택 → 결재함 대기", async ({ page }) => { - await page.goto("/mail"); - await page.locator(".mrow", { hasText: "온보딩 시안 v3" }).first().click(); - 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("메일 계정 연결 진입점은 설정 메일 탭", async ({ page }) => { + await page.goto("/settings?tab=mail"); + await expect(page.getByRole("button", { name: /계정 추가/ })).toBeVisible(); }); -test("계정 필터 — 사이드(side) 전환", async ({ page }) => { +test("빈 메일 페이지 a11y — 위반 0건", async ({ page }) => { await page.goto("/mail"); - await page.getByText("사이드", { exact: false }).first().click(); - await expect(page.locator(".mrow", { hasText: "수요 독서모임" }).first()).toBeVisible(); + await expect(page.locator(".mailpage")).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([]); }); diff --git a/frontend/playwright/multiuser.spec.ts b/frontend/playwright/multiuser.spec.ts index f1cd2cf..db6c975 100644 --- a/frontend/playwright/multiuser.spec.ts +++ b/frontend/playwright/multiuser.spec.ts @@ -9,20 +9,44 @@ async function login(page, email: string) { 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 ctxB = await browser.newContext(); const pa = await ctxA.newPage(); const pb = await ctxB.newPage(); await login(pa, "jiwoo@lumi.co"); - await pa.goto("/tasks"); - await expect(pa.getByText("분기 리포트 초안 마무리").first()).toBeVisible(); // 지우 시드 작업 + await pa.goto("/inbox"); + 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 pb.goto("/tasks"); - // 현우는 소유 작업 0 → 시드 작업(분기 리포트) 안 보임 - await expect(pb.getByText("분기 리포트 초안 마무리")).toHaveCount(0); + await expect(pb.locator(".kcard")).toHaveCount(0); await ctxA.close(); await ctxB.close(); diff --git a/frontend/playwright/settings.spec.ts b/frontend/playwright/settings.spec.ts index 12cc8b1..f29563e 100644 --- a/frontend/playwright/settings.spec.ts +++ b/frontend/playwright/settings.spec.ts @@ -19,18 +19,22 @@ test("6개 탭이 보이고 기본은 계정 탭", async ({ page }) => { 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 expect(page.getByRole("heading", { level: 2, name: "메일 계정" })).toBeVisible(); - await expect(page.locator(".cn-grid .cn-card")).toHaveCount(3); - await expect(page.locator(".cn-count")).toHaveText("3/3 연결됨"); + await expect(page.locator(".cn-grid .cn-card")).toHaveCount(0); + await expect(page.getByText(/연결된 메일 계정이 없어요/)).toBeVisible(); }); -test("메일 동기화 → 토스트", async ({ page }) => { +test("메일 탭 — 계정 추가 메뉴(Gmail·Outlook, 미구성 비활성)", async ({ page }) => { await page.getByRole("button", { name: /^메일/ }).click(); - const work = page.locator(".cn-card", { hasText: "회사" }).first(); - await work.getByRole("button", { name: /동기화/ }).click(); - await expect(page.locator(".au-toast")).toContainText("새 항목"); + await page.getByRole("button", { name: /계정 추가/ }).click(); + const menu = page.getByRole("menu", { name: /추가할 계정 제공자/ }); + 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 }) => { diff --git a/frontend/playwright/shell.spec.ts b/frontend/playwright/shell.spec.ts index 5337852..312b4b7 100644 --- a/frontend/playwright/shell.spec.ts +++ b/frontend/playwright/shell.spec.ts @@ -6,9 +6,9 @@ test("/ → /dashboard 리다이렉트", async ({ page }) => { await expect(page).toHaveURL(/\/dashboard$/); }); -test("12개 내비 항목이 모든 페이지에서 보인다", async ({ page }) => { +test("내비 항목이 모든 페이지에서 보인다", async ({ page }) => { await page.goto("/tasks"); - for (const label of ["대시보드", "인박스", "결재함", "작업", "라이프"]) { + for (const label of ["대시보드", "인박스", "결재함", "작업", "메일"]) { await expect(page.getByRole("link", { name: new RegExp(label) })).toBeVisible(); } }); diff --git a/frontend/playwright/tasks.spec.ts b/frontend/playwright/tasks.spec.ts index d58185d..c932278 100644 --- a/frontend/playwright/tasks.spec.ts +++ b/frontend/playwright/tasks.spec.ts @@ -1,68 +1,29 @@ // frontend/playwright/tasks.spec.ts -// 주의: 백엔드(:31800) 시드 DB 를 변경하는 테스트 포함 → 단일 워커 직렬 실행. +// phase-16+: 작업 mock 시드 제거 → 빈 칸반에서 시작. UI 로 만들 수 있는 흐름만 검증. import { expect, test } from "@playwright/test"; import { resetSeed } from "./fixtures/seed-reset"; test.describe.configure({ mode: "serial" }); -// 깨끗한 시드에서 시작(앞선 spec 의 변경과 격리). -test.beforeAll(resetSeed); +test.beforeEach(resetSeed); -test("초기 진입: 업무 스코프 + 칸반 5컬럼 + 리스크 레이더", async ({ page }) => { +test("초기 진입: 업무 스코프 + 칸반 5컬럼(빈) + 리스크 레이더", async ({ page }) => { await page.goto("/tasks"); await expect(page.locator(".tree-row.folder.on .tree-name")).toHaveText("업무"); await expect(page.locator(".kboard .kcol")).toHaveCount(5); - await expect(page.locator(".rradar")).toBeVisible(); - await expect(page.locator(".rr-ht b")).toHaveText("리스크 레이더"); -}); - -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("분기 리포트"); + await expect(page.locator(".kcard")).toHaveCount(0); // 작업 시드 제거 → 빈 보드 + // 리스크 레이더는 위험이 있을 때만 렌더(빈 보드 → 없음) + await expect(page.locator(".rradar")).toHaveCount(0); }); test("뷰 전환 칸반 ↔ 리스트", async ({ page }) => { await page.goto("/tasks"); await page.locator(".view-seg button", { hasText: "리스트" }).click(); await expect(page.locator(".lgroups")).toBeVisible(); - await expect(page.locator(".lgroup").first()).toBeVisible(); await page.locator(".view-seg button", { hasText: "칸반" }).click(); 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 }) => { await page.goto("/tasks"); await page.locator(".tree-add", { hasText: "새 프로젝트" }).first().click(); @@ -72,32 +33,3 @@ test("새 프로젝트 생성 → 서버 영속(새로고침 유지)", async ({ await page.reload(); 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(); -}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts index b13c2f2..265f478 100644 --- a/frontend/tests/setup.ts +++ b/frontend/tests/setup.ts @@ -5,6 +5,22 @@ import { toHaveNoViolations } from "vitest-axe/dist/matchers"; 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 — 여정 베지어 보드가 사용) if (!("ResizeObserver" in globalThis)) { (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {