vignette/apps/web/e2e/uc-auth-onboarding.spec.ts
Yun Chan cf899b7f15 유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정
- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나
  웹 진입점이 없던 2결함
- a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩
  그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정
- uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정
- breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정
  — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로
- 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN),
  직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소,
  교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기
- 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json,
  SSOT 체커 PASS(59)
2026-08-18 11:47:58 +09:00

416 lines
18 KiB
TypeScript

/* =====================================================================
uc-auth-onboarding.spec.ts — 로그인·온보딩·승인대기·라우트 가드 유스케이스.
목적: 실제 사용자가 겪는 인증 여정(로그인 화면 확인 → 딥링크 가드 →
온보딩 입력 검증 → 승인 대기 → 역할별 홈 진입 → 세션 복원)을
route fixture만으로 검증한다. 실 API·dev-login 헬퍼를 쓰지 않으며,
실제 AI 엔진 턴 생성은 없다.
근거:
- src/pages/Login.tsx + login/LoginBrand.tsx + login/LoginPanel.tsx
(허용 도메인 배지, 비치료 고지 문구, OAuth 준비 상태)
- src/pages/Onboarding.tsx (canSubmit 필수 입력·동의 검증, isValidPhone,
약관 details, source_note)
- src/pages/PendingApproval.tsx (승인 상태 새로고침, 아직 승인 전 안내)
- src/App.tsx (RequireAuth / OnboardingGate / PendingApprovalGate /
RootRedirect / 미정의 경로 → /)
- src/lib/auth.tsx (userFromMe 필드, initialPathForUser, roleHomePath)
중복 회피: auth.spec.ts(실 API 도메인 정책·실 온보딩 완주),
full-sweep-auth.spec.ts(아바타 업로드·문서 폴백·pending 새로고침 승인 경로),
full-sweep-shell.spec.ts(로그아웃·미정의 경로의 role-aware 루트)와 겹치지
않도록 이 파일은 입력 검증·미승인 유지 경로·역할별 루트·관리자 온보딩
우회 분기 등 다른 각도만 다룬다.
===================================================================== */
import { test, expect, type Page } from "@playwright/test";
function jsonRoute(body: unknown, status = 200) {
return { status, contentType: "application/json", body: JSON.stringify(body) };
}
/** catch-all: fixture로 지정하지 않은 API는 404. 반드시 가장 먼저 등록. */
async function routeUnmockedApi(page: Page) {
await page.route("**/api/**", (route) =>
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
);
}
/** OAuth·dev 로그인 구성 fixture (Login.tsx가 GET /auth/config로 조회). */
const READY_AUTH_CONFIG = {
google_oauth_configured: true,
saml_configured: false,
providers: [
{ provider: "google", configured: true, enabled: true, login_path: "/auth/login?provider=google" },
{ provider: "saml", configured: false, enabled: false, login_path: "/auth/login?provider=saml" },
],
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
};
async function routeAuthConfig(page: Page, body: unknown = READY_AUTH_CONFIG) {
await page.route("**/api/auth/config", (route) => route.fulfill(jsonRoute(body)));
}
/** userFromMe(auth.tsx)가 읽는 필드를 빠짐없이 채운 /auth/me 응답. */
function makeMe(overrides: Record<string, unknown> = {}) {
return {
user_id: "e2e-uc-auth-1",
email: "learner@hs.ac.kr",
role: "learner",
display_name: "학습자",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "학습자",
self_introduction: "",
avatar_url: "",
...overrides,
};
}
/** GET /auth/me를 동적 본문으로 고정한다(나중 등록이 catch-all보다 우선). */
async function routeMe(page: Page, body: () => Record<string, unknown> | null) {
await page.route("**/api/auth/me", (route) => {
const current = body();
if (current === null) {
return route.fulfill(jsonRoute({ detail: "not_authenticated" }, 401));
}
return route.fulfill(jsonRoute(current));
});
}
async function routeUnauthenticated(page: Page) {
await routeMe(page, () => null);
}
/** 온보딩 화면이 GET /users/me로 읽는 미완료 학습자 프로필 fixture. */
const FRESH_PROFILE = {
user_id: "e2e-uc-auth-1",
email: "learner@hs.ac.kr",
role: "learner",
display_name: "",
legal_name: "",
affiliation: "",
department: "",
grade_level: "",
phone: "",
contact_address: "",
nickname: "",
self_introduction: "",
avatar_url: "",
cohort_ids: [],
};
/** 온보딩 화면이 GET /users/legal-docs로 읽는 약관 fixture. */
const LEGAL_DOCS = {
terms: {
kind: "terms",
status: "draft",
title: "서비스 이용약관",
version: "v0.9-draft",
body: "제1조(목적) 이 약관은 Vignette 교육용 시뮬레이션 서비스의 이용 조건을 규정합니다.",
},
privacy: {
kind: "privacy",
status: "draft",
title: "개인정보 처리방침",
version: "v0.9-draft",
body: "제1조(수집 항목) 이름, 소속, 연락처를 가입 확인 목적으로 수집합니다.",
},
source_note: "운영 전 초안 문서입니다.",
};
async function routeOnboardingData(page: Page) {
await page.route("**/api/users/me", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill(jsonRoute(FRESH_PROFILE));
});
await page.route("**/api/users/legal-docs", (route) =>
route.fulfill(jsonRoute(LEGAL_DOCS)),
);
}
/** 온보딩 미완료 learner로 인증된 상태 + 온보딩 데이터 fixture. */
async function setupFreshLearnerOnOnboarding(page: Page) {
await routeUnmockedApi(page);
await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" }));
await routeOnboardingData(page);
}
/** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */
async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
await page.getByLabel("닉네임").fill("비넷 유스케이스");
await page.getByLabel("자기소개").fill("상담 시뮬레이션에서 경청 반응을 연습합니다.");
await page.getByLabel("이름").fill("유스케이스 학습자");
await page.getByLabel("소속").fill("한신대학교");
await page.getByLabel("학과/부서").fill("상담심리학과");
await page.getByLabel("학년/직위").fill("2학년");
await page.getByLabel("연락처").fill(phone);
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
}
test.describe("uc auth-onboarding", () => {
// usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·허용 도메인·비치료 고지를 읽는다
test("로그인 화면에서 서비스 성격과 허용 도메인, 비치료 고지를 확인한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeUnauthenticated(page);
await routeAuthConfig(page);
await page.goto("/login");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
await expect(page.locator(".lg-lead")).toContainText(
"학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.",
);
// 허용 도메인 안내: 브랜드 패널 배지 + 버튼 서브라벨.
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
await expect(page.locator(".lg-policy b").first()).toHaveText("hs.ac.kr");
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
// 비치료 고지: 교육용 도구임을 명시한다.
await expect(page.locator(".lg-note")).toContainText(
"교육용 비치료 연구 도구입니다. 실제 치료, 진단, 위기 개입을 대체하지 않습니다.",
);
});
// usecase: 로그인하지 않은 사용자가 보호 경로 딥링크(북마크)로 들어가면 로그인 화면으로 보낸다
test("비인증 사용자가 보호 경로 딥링크로 들어가면 /login으로 이동한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeUnauthenticated(page);
await routeAuthConfig(page);
for (const deepLink of ["/learn/practice", "/teach/supervision", "/settings"]) {
await page.goto(deepLink);
await page.waitForURL(/\/login(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
}
});
// usecase: 이미 로그인한 학습자가 /login 북마크를 다시 열면 자신의 홈으로 되돌린다
test("로그인된 학습자가 /login을 다시 열면 학습자 홈으로 튕긴다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () => makeMe());
await page.goto("/login");
await page.waitForURL(/\/learn(?:$|[/?#])/);
await expect(page).not.toHaveURL(/\/login/);
});
// usecase: 신규 사용자가 온보딩에서 아무것도 입력하지 않거나 닉네임만 넣고 제출하려 한다
test("온보딩 필수 입력이 비어 있으면 제출 버튼이 비활성 상태로 막힌다", async ({ page }) => {
await setupFreshLearnerOnOnboarding(page);
await page.goto("/onboarding");
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
const submit = page.getByRole("button", { name: /가입 설정 완료/ });
await expect(submit).toBeDisabled();
// 닉네임만 채워도 나머지 필수 입력이 비어 있으면 여전히 제출할 수 없다.
await page.getByLabel("닉네임").fill("절반만 입력");
await expect(submit).toBeDisabled();
});
// usecase: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다
test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => {
await setupFreshLearnerOnOnboarding(page);
await page.goto("/onboarding");
await fillOnboardingTextFields(page);
const submit = page.getByRole("button", { name: /가입 설정 완료/ });
await expect(submit).toBeDisabled();
// 약관 하나만 동의해도 아직 부족하다.
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
await expect(submit).toBeDisabled();
// 개인정보 동의까지 체크하면 비로소 제출할 수 있다.
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
await expect(submit).toBeEnabled();
});
// usecase: 신규 사용자가 동의 전에 약관·개인정보 문서를 펼쳐 실제 내용을 읽어 본다
test("온보딩 약관·개인정보 문서를 펼쳐 제목·버전·본문을 읽는다", async ({ page }) => {
await setupFreshLearnerOnOnboarding(page);
await page.goto("/onboarding");
const details = page.locator(".ob-legal details");
await expect(details).toHaveCount(2);
await expect(details.nth(0).locator("summary")).toContainText("서비스 이용약관");
await expect(details.nth(0).locator("summary b")).toHaveText("v0.9-draft");
await details.nth(0).locator("summary").click();
await expect(details.nth(0).locator(".ob-doc-text")).toContainText("제1조(목적)");
await expect(details.nth(1).locator("summary")).toContainText("개인정보 처리방침");
await details.nth(1).locator("summary").click();
await expect(details.nth(1).locator(".ob-doc-text")).toContainText("제1조(수집 항목)");
// 문서 출처 주석(source_note)도 함께 표시된다.
await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다.");
});
// usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다
test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => {
await setupFreshLearnerOnOnboarding(page);
let onboardingPosted = false;
await page.route("**/api/users/me/onboarding", (route) => {
onboardingPosted = true;
return route.fulfill(jsonRoute(FRESH_PROFILE));
});
await page.goto("/onboarding");
await fillOnboardingTextFields(page, "12"); // 숫자 9자리 미만 → isValidPhone 실패
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
await page.getByRole("button", { name: /가입 설정 완료/ }).click();
const alert = page.locator(".ob-form > .ob-error[role='alert']");
await expect(alert).toBeVisible();
await expect(alert).toContainText("연락처는 숫자 9~15자리의 전화번호 형식으로 입력해 주세요");
await expect(page).toHaveURL(/\/onboarding(?:$|[/?#])/);
expect(onboardingPosted).toBe(false);
});
// usecase: 승인 대기 중인 학습자가 새로고침 버튼을 눌러 보지만 아직 승인 전이다
test("승인 대기 화면에서 새로고침해도 아직 승인 전이면 시각과 함께 안내한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () => makeMe({ account_status: "pending", approval_required: true }));
await page.goto("/");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
await page.getByRole("button", { name: "승인 상태 새로고침" }).click();
await expect(page.locator(".pa-status")).toContainText(
"기준 아직 승인 전입니다. 잠시 후 다시 확인해 주세요.",
);
await expect(page).toHaveURL(/\/pending(?:$|[/?#])/);
});
// usecase: 승인 대기 계정이 온보딩·학습 딥링크를 직접 열어도 승인 대기 화면으로 강제 이동한다
test("pending 계정은 온보딩·학습 딥링크에서도 /pending으로 강제 이동한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () =>
makeMe({ account_status: "pending", approval_required: true, onboarding_completed_at: null }),
);
for (const deepLink of ["/onboarding", "/learn", "/settings"]) {
await page.goto(deepLink);
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(
page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }),
).toBeVisible();
}
});
// usecase: 승인·온보딩을 마친 학습자가 루트 주소로 들어와 자신의 홈으로 안내받는다
test("학습자가 루트(/)로 들어오면 /learn으로 리디렉트된다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () => makeMe());
await page.goto("/");
await page.waitForURL(/\/learn(?:$|[/?#])/);
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
});
// usecase: 교수자가 루트 주소로 들어와 교수 콘솔로 안내받는다
test("교수자가 루트(/)로 들어오면 /teach로 리디렉트된다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () =>
makeMe({
user_id: "e2e-uc-auth-teacher",
email: "teacher@hs.ac.kr",
role: "teacher",
display_name: "교수자",
nickname: "교수자",
}),
);
await page.goto("/");
await page.waitForURL(/\/teach(?:$|[/?#])/);
await expect(page.locator("body")).toHaveAttribute("data-role", "instructor");
});
// usecase: 관리자가 루트 주소로 들어와 운영 콘솔로 안내받는다
test("관리자가 루트(/)로 들어오면 /admin으로 리디렉트된다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () =>
makeMe({
user_id: "e2e-uc-auth-admin",
email: "admin@twentyoz.kr",
role: "admin",
display_name: "관리자",
nickname: "관리자",
admin_access: true,
}),
);
await page.goto("/");
await page.waitForURL(/\/admin(?:$|[/?#])/);
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
});
// usecase: 학습자가 브라우저를 새로고침해도 저장된 세션(auth/me)으로 로그인 상태가 복원된다
test("새로고침 후에도 auth/me 기반으로 세션이 복원되어 /learn에 머문다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () => makeMe());
await page.goto("/learn");
await expect(page).toHaveURL(/\/learn(?:$|[/?#])/);
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
await page.reload();
await expect(page).toHaveURL(/\/learn(?:$|[/?#])/);
await expect(page.locator("body")).toHaveAttribute("data-role", "learner");
await expect(page).not.toHaveURL(/\/login/);
});
// usecase: 사용자가 잘못된 주소를 입력하면 상태에 맞는 화면(/login 또는 /pending)으로 정리된다
test("알 수 없는 경로는 비인증이면 /login, 승인 대기면 /pending으로 정리된다", async ({ page }) => {
await routeUnmockedApi(page);
await routeAuthConfig(page);
// 비인증 사용자의 오타 주소 → 루트 정리 → /login.
let me: Record<string, unknown> | null = null;
await routeMe(page, () => me);
await page.goto("/no-such-route");
await page.waitForURL(/\/login(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
// 승인 대기 계정의 오타 주소 → /pending.
me = makeMe({ account_status: "pending", approval_required: true });
await page.goto("/typo/deeper/route");
await page.waitForURL(/\/pending(?:$|[/?#])/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
});
// usecase: 온보딩을 아직 안 끝낸 관리자가 운영 콘솔 주소로 바로 들어간다(관리자 우회 분기)
test("온보딩 미완료 관리자는 /admin 작업 공간에 온보딩 없이 바로 접근한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () =>
makeMe({
user_id: "e2e-uc-auth-admin-fresh",
email: "admin@twentyoz.kr",
role: "admin",
display_name: "관리자",
nickname: "관리자",
admin_access: true,
onboarding_completed_at: null,
}),
);
await page.goto("/admin");
// OnboardingGate의 isAdminWorkspacePath 분기: /onboarding으로 밀려나지 않는다.
await expect(page).toHaveURL(/\/admin(?:$|[/?#])/);
await expect(page).not.toHaveURL(/\/onboarding/);
await expect(page.locator("body")).toHaveAttribute("data-role", "admin");
});
});