vignette/apps/web/e2e/uc-admin-console.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

1044 lines
40 KiB
TypeScript

/**
* uc-admin-console.spec.ts — 관리자 콘솔 유스케이스 여정 스펙
*
* 목적: 운영 관리자가 실제로 수행하는 콘솔 여정을 route fixture만으로 검증한다.
* - /admin/access 권한 정책 화면 탭 조작(역할·그룹·권한 매트릭스)
* - 신규 가입 승인·보류 처리 플로우(PATCH 본문·큐 갱신·요약 카운트)
* - 운영 티켓 상세 열람, 해결 처리, 해결 노트 작성 기대(learner Settings가 노출하는
* resolution_note를 관리자가 작성할 수단이 있는지)
* - AI 엔진 설정 변경 확인 단계: 게이트웨이 검증 fail-closed 표시, 연결 주소 변경 재검증
* - 서비스 헬스 이벤트/지표 표시(overview)
* - G8 지속 개선 게이트: 승인 차단 사유 상시 노출과 키보드(Enter) 제출
* - 사용자 검색(슈퍼 관리자 키워드·소속 필드)
*
* 근거: src/pages/Admin.tsx, src/pages/AdminAi.tsx,
* src/pages/admin/ContinuousImprovementCockpit.tsx, src/lib/api.gen.ts 계약.
* 실 API 없이 전부 route fixture로 동작하며, 실제 AI 엔진 턴 생성은 없다.
* 기존 admin.spec.ts / full-sweep-admin*.spec.ts / continuous-improvement-admin.spec.ts가
* 다룬 시나리오(역할별 라우트 가드, 검색 by 이메일·역할·코호트, 티켓 필터 쿼리,
* usage 윈도 전환, 콘텐츠·모델 게이트 승인 플로우)는 반복하지 않는다.
*/
import { expect, test, type Page, type Route } from "@playwright/test";
const NOW = 1_755_400_000; // 2026-08-17 근처 고정 기준
const CREATED_AT = "2026-08-06T08:30:00Z";
const MODEL_GATE_ID = "90000000-0000-4000-8000-000000000001";
const RELEASE_GATE_ID = "90000000-0000-4000-8000-000000000002";
function jsonRoute(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
interface FixtureUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
admin_access: boolean;
super_admin: boolean;
account_status: "pending" | "approved" | "suspended";
affiliation: string;
cohort_ids: string[];
active_sessions: number;
created_at: number;
last_seen_at: number;
source: "database";
}
function makeUser(overrides: Partial<FixtureUser> & { user_id: string }): FixtureUser {
return {
email: `${overrides.user_id}@hs.ac.kr`,
display_name: overrides.user_id,
role: "learner",
admin_access: false,
super_admin: false,
account_status: "approved",
affiliation: "",
cohort_ids: [],
active_sessions: 0,
created_at: NOW - 86_400,
last_seen_at: NOW - 3_600,
source: "database",
...overrides,
};
}
interface FixtureTicket {
ticket_id: string;
reporter: { email: string; display_name: string; role: string };
category: string;
priority: "low" | "normal" | "high" | "urgent";
status: "open" | "triaged" | "in_progress" | "resolved" | "closed";
subject: string;
body: string;
source_path: string;
assigned_group: string;
fingerprint: string;
resolution_note: string;
event_count: number;
last_event_at: number | null;
parent_ticket_id: string | null;
duplicate_count: number;
duplicate_parent_candidate_id: string | null;
child_ticket_count: number;
resolved_at: number | null;
created_at: number;
updated_at: number;
}
function makeTicket(
overrides: Partial<FixtureTicket> & { ticket_id: string; subject: string },
): FixtureTicket {
return {
reporter: { email: "learner1@hs.ac.kr", display_name: "이서연", role: "learner" },
category: "session_review",
priority: "normal",
status: "open",
body: "문제 상황 설명",
source_path: "/learn",
assigned_group: "",
fingerprint: "f".repeat(64),
resolution_note: "",
event_count: 0,
last_event_at: null,
parent_ticket_id: null,
duplicate_count: 0,
duplicate_parent_candidate_id: null,
child_ticket_count: 0,
resolved_at: null,
created_at: NOW - 40_000,
updated_at: NOW - 20_000,
...overrides,
};
}
function ticketsSummary(tickets: FixtureTicket[]) {
const active = tickets.filter(
(ticket) => ticket.status !== "resolved" && ticket.status !== "closed",
);
const byCategory: Record<string, number> = {};
for (const ticket of active) {
byCategory[ticket.category] = (byCategory[ticket.category] ?? 0) + 1;
}
return {
total: tickets.length,
open_count: active.length,
high_priority_count: active.filter(
(ticket) => ticket.priority === "high" || ticket.priority === "urgent",
).length,
stale_count: 0,
by_category: byCategory,
by_priority: {},
by_status: {},
};
}
interface AdminMockOptions {
users?: FixtureUser[];
tickets?: FixtureTicket[];
services?: Array<{
key: string;
name: string;
status: "ok" | "degraded" | "down";
detail: string;
metric: string;
load: number;
}>;
healthStatus?: "ok" | "degraded" | "down";
engineMode?: string;
uptime?: { sample_count: number; ok_ratio: number; last_down_at: number | null };
capabilitiesAvailable?: boolean;
capabilitiesDetail?: string;
superAdmin?: boolean;
}
/**
* 관리자 세션 + 운영 콘솔 API 전체를 fixture로 응답한다.
* catch-all(404) 뒤에 개별 분기가 우선하도록 단일 핸들러 내부에서 분기한다.
* 사용자/티켓 PATCH는 상태를 갱신해 이후 재조회에 반영한다(결정론 유지).
*/
async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
const users = options.users ?? [];
let tickets = options.tickets ?? [];
const patchedUserBodies: Array<Record<string, unknown>> = [];
const patchedTicketBodies: Array<Record<string, unknown>> = [];
await page.route("**/api/**", async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const method = request.method();
const fulfill = (body: unknown, status = 200) => route.fulfill(jsonRoute(body, status));
if (method === "GET" && path.endsWith("/auth/me")) {
await fulfill({
user_id: "uc-admin-1",
email: "uc-admin@twentyoz.kr",
display_name: "운영 관리자",
role: "admin",
admin_access: true,
super_admin: options.superAdmin ?? true,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: NOW - 1_000_000,
onboarding_completed_at: NOW - 1_000_000,
nickname: "운영",
self_introduction: "",
avatar_url: "",
});
return;
}
if (method === "GET" && path.endsWith("/admin/health")) {
await fulfill({
status: options.healthStatus ?? "ok",
environment: "dev",
engine_mode: options.engineMode ?? "claude_cli",
services: options.services ?? [
{
key: "engine",
name: "AI 엔진",
status: "ok",
detail: "게이트웨이 응답 정상",
metric: "240ms",
load: 0.24,
},
],
});
return;
}
if (method === "GET" && path.endsWith("/admin/users")) {
await fulfill({ source: "database", durable: true, users });
return;
}
const userPatch = /\/admin\/users\/([^/]+)$/.exec(path);
if (method === "PATCH" && userPatch) {
const body = request.postDataJSON() as Record<string, unknown>;
patchedUserBodies.push({ user_id: userPatch[1], ...body });
const index = users.findIndex((user) => user.user_id === userPatch[1]);
if (index === -1) {
await fulfill({ detail: "unknown user" }, 404);
return;
}
users[index] = { ...users[index], ...body } as FixtureUser;
await fulfill(users[index]);
return;
}
if (method === "GET" && path.endsWith("/admin/usage")) {
await fulfill({
source: "database",
durable: true,
generated_at: NOW - 600,
window_days: Number(url.searchParams.get("window_days") ?? 7),
total_turns: 12,
metered_turns: 12,
token_metered_turns: 12,
token_unmetered_turns: 0,
tokens_in: 42_000,
tokens_out: 5_600,
cost_usd: 1.25,
recorded_cost_usd: 1.25,
estimated_cost_usd: 0,
budget: { limit_usd: 20, used_ratio: 0.0625, remaining_usd: 18.75, status: "ok" },
evaluator_cache: {
enabled: true,
entries: 4,
hits: 6,
misses: 2,
stores: 2,
evictions: 0,
requests: 8,
hit_rate: 0.75,
},
by_provider: [],
daily_cost: [],
});
return;
}
if (method === "GET" && path.endsWith("/admin/uptime")) {
const uptime = options.uptime ?? {
sample_count: 288,
ok_ratio: 0.996,
last_down_at: NOW - 200_000,
};
await fulfill({
source: "database",
durable: true,
window_hours: 24,
sample_count: uptime.sample_count,
ok_ratio: uptime.ok_ratio,
degraded_events: 1,
down_events: 1,
last_down_at: uptime.last_down_at,
});
return;
}
if (method === "GET" && path.endsWith("/admin/tickets")) {
await fulfill({
source: "database",
durable: true,
generated_at: NOW - 300,
tickets,
summary: ticketsSummary(tickets),
});
return;
}
const ticketPatch = /\/admin\/tickets\/([^/]+)$/.exec(path);
if (method === "PATCH" && ticketPatch) {
const body = request.postDataJSON() as Record<string, unknown>;
patchedTicketBodies.push({ ticket_id: ticketPatch[1], ...body });
tickets = tickets.map((ticket) =>
ticket.ticket_id === ticketPatch[1]
? {
...ticket,
...body,
resolved_at:
body.status === "resolved" ? NOW - 100 : ticket.resolved_at,
updated_at: NOW - 100,
}
: ticket,
) as FixtureTicket[];
const updated = tickets.find((ticket) => ticket.ticket_id === ticketPatch[1]);
await fulfill(updated ?? { detail: "unknown ticket" }, updated ? 200 : 404);
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
await fulfill({
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
reasoning_effort: "medium",
source: "database",
durable: true,
updated_by: "uc-admin@twentyoz.kr",
updated_at: NOW - 5_000,
});
return;
}
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
if (options.capabilitiesAvailable === false) {
await fulfill({
provider: url.searchParams.get("engine_mode") ?? "openai",
available: false,
source: "error",
models: [],
default_model: null,
default_reasoning_effort: null,
detail:
options.capabilitiesDetail ??
"게이트웨이에 연결할 수 없어 모델 검증에 실패했습니다.",
fetched_at: NOW - 10,
});
return;
}
await fulfill({
provider: url.searchParams.get("engine_mode") ?? "openai",
available: true,
source: "live_cli",
models: [
{
id: "gateway-default",
label: "게이트웨이 기본 모델",
description: "fixture 기본 모델",
reasoning_efforts: ["low", "medium", "high"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: "gateway-default",
default_reasoning_effort: "medium",
detail: "fixture 모델 목록",
fetched_at: NOW - 10,
});
return;
}
await fulfill({ detail: `unmocked API request: ${method} ${path}` }, 404);
});
return { patchedUserBodies, patchedTicketBodies };
}
/** G8 지속 개선 콕핏 fixture — 모델 게이트는 증거 2종만, 릴리스 게이트는 4종 완비. */
function ciView() {
const artifact = (
suffix: string,
ownerKind: "model_change_gate" | "release_gate",
ownerId: string,
kind: "baseline" | "threshold" | "provenance" | "rollback",
) => ({
artifact_record_id: `91000000-0000-4000-8000-0000000000${suffix}`,
owner_kind: ownerKind,
owner_id: ownerId,
artifact_kind: kind,
artifact_id: `uc-g8-${kind}-${suffix}`,
content_sha256: kind.slice(0, 1).repeat(64),
provenance_uri: `audit://uc/g8/${ownerId}/${kind}`,
created_at: CREATED_AT,
});
return {
content_qualifications: [],
model_change_gates: [
{
gate_id: MODEL_GATE_ID,
gate_decision: "promote",
reasons: ["synthetic benchmark threshold passed"],
state: "pending_human_approval",
created_at: CREATED_AT,
},
],
release_gates: [
{
gate_id: RELEASE_GATE_ID,
release_id: "uc-g8-release-2026-08-17",
qualified: true,
state: "pending_human_approval",
created_at: CREATED_AT,
},
],
gate_artifacts: [
artifact("01", "model_change_gate", MODEL_GATE_ID, "baseline"),
artifact("02", "model_change_gate", MODEL_GATE_ID, "threshold"),
artifact("05", "release_gate", RELEASE_GATE_ID, "baseline"),
artifact("06", "release_gate", RELEASE_GATE_ID, "threshold"),
artifact("07", "release_gate", RELEASE_GATE_ID, "provenance"),
artifact("08", "release_gate", RELEASE_GATE_ID, "rollback"),
],
approvals: [] as Array<Record<string, unknown>>,
catalog_entries: [] as Array<Record<string, unknown>>,
lifecycle_events: [] as Array<Record<string, unknown>>,
incidents: [],
regression_dag_nodes: [],
data_classification: "synthetic_replay_red_team_coverage_drift",
silent_auto_promotion_allowed: false,
raw_transcript_included: false,
pii_included: false,
clinical_claim_allowed: false,
};
}
async function installCiMock(page: Page) {
const view = ciView();
const approvalBodies: Array<Record<string, unknown>> = [];
await page.route("**/api/**", async (route: Route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const fulfill = (body: unknown, status = 200) => route.fulfill(jsonRoute(body, status));
if (request.method() === "GET" && path.endsWith("/auth/me")) {
await fulfill({
user_id: "uc-ci-admin-1",
email: "uc-ci-admin@twentyoz.kr",
display_name: "개선 관리자",
role: "admin",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: NOW - 1_000_000,
onboarding_completed_at: NOW - 1_000_000,
nickname: "",
self_introduction: "",
avatar_url: "",
});
return;
}
if (request.method() === "GET" && path.endsWith("/continuous-improvement")) {
await fulfill(view);
return;
}
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
const body = request.postDataJSON() as Record<string, unknown>;
approvalBodies.push(body);
view.approvals.push({
approval_event_id: body.approval_event_id,
target_kind: body.target_kind,
target_id: body.target_id,
decision: body.decision,
reason_code: body.reason_code,
evidence_refs: body.evidence_refs,
created_at: CREATED_AT,
});
view.lifecycle_events.unshift({
lifecycle_event_id: body.effect_record_id,
target_kind: body.target_kind,
target_id: body.target_id,
event_type: "promotion",
event_status: "approved",
evidence_refs: body.evidence_refs,
created_at: CREATED_AT,
});
await fulfill(
{
submission_id: body.submission_id,
approval_event_id: body.approval_event_id,
target_kind: body.target_kind,
target_id: body.target_id,
decision: body.decision,
effect_record_id: body.effect_record_id,
idempotent_replay: false,
},
201,
);
return;
}
await fulfill({ detail: `unmocked ${request.method()} ${path}` }, 404);
});
return { view, approvalBodies };
}
test.describe("관리자 콘솔 유스케이스 여정", () => {
// usecase: 관리자가 권한 정책 화면에서 역할 탭을 확인하고 그룹 탭으로 전환한다
test("권한 화면에서 역할 정책을 읽고 그룹 정책 탭으로 전환한다", async ({ page }) => {
await installAdminMock(page);
await page.goto("/admin/access");
await expect(
page.getByRole("heading", { name: "역할, 그룹, 접근 범위" }),
).toBeVisible();
// 기본 탭은 역할 — 3개 역할 정책 카드가 권한 칩과 함께 보인다.
await expect(page.getByRole("tab", { name: "역할" })).toHaveAttribute(
"aria-selected",
"true",
);
const adminPolicy = page.locator(".vgops-policy").filter({
has: page.getByRole("heading", { name: "관리자" }),
});
await expect(adminPolicy).toContainText("4개 권한");
await expect(adminPolicy).toContainText("서비스 헬스");
await expect(adminPolicy).toContainText("학습자 상담 내용은 기본 운영 화면에서 제외");
await expect(
page.locator(".vgops-policy").filter({
has: page.getByRole("heading", { name: "학습자" }),
}),
).toContainText("타 사용자 데이터와 운영 리소스 접근 불가");
// 그룹 탭으로 전환하면 운영 그룹 정책 초안 카드가 대신 보인다.
await page.getByRole("tab", { name: "그룹" }).click();
await expect(page.getByRole("tab", { name: "그룹" })).toHaveAttribute(
"aria-selected",
"true",
);
const operatorGroup = page.locator(".vgops-policy").filter({
has: page.getByRole("heading", { name: "서비스 운영자" }),
});
await expect(operatorGroup).toContainText("정책 초안");
await expect(operatorGroup).toContainText("티켓 큐");
await expect(page.getByRole("heading", { name: "관리자", exact: true })).toHaveCount(0);
});
// usecase: 관리자가 권한 매트릭스 탭에서 리소스별 역할 권한을 대조한다
test("권한 매트릭스 탭에서 AI 운영 설정 접근 범위를 확인한다", async ({ page }) => {
await installAdminMock(page);
await page.goto("/admin/access");
await page.getByRole("tab", { name: "권한 매트릭스" }).click();
const table = page.locator(".vgops-access-table");
await expect(table).toBeVisible();
await expect(table.locator(".vgops-access-table__head")).toContainText("리소스");
await expect(table.locator(".vgops-access-table__head")).toContainText("교수자");
const aiRow = table.locator(".vgops-access-row").filter({ hasText: "AI 운영 설정" });
await expect(aiRow).toContainText("수정");
const aiRowCells = aiRow.locator("span");
await expect(aiRowCells.nth(0)).toHaveText("수정");
await expect(aiRowCells.nth(1)).toHaveText("없음");
await expect(aiRowCells.nth(2)).toHaveText("없음");
const reviewRow = table.locator(".vgops-access-row").filter({ hasText: "회기 리뷰" });
await expect(reviewRow).toContainText("담당 코호트");
await expect(reviewRow).toContainText("본인 회기");
});
// usecase: 관리자가 신규 가입 요청을 승인해 대기 큐를 비운다
test("가입 승인 큐에서 신규 가입을 승인하면 큐와 카운트가 갱신된다", async ({ page }) => {
const pendingUser = makeUser({
user_id: "signup-1",
email: "new-learner@hs.ac.kr",
display_name: "신규 학습자",
account_status: "pending",
affiliation: "상담학과",
cohort_ids: ["cohort-2026a"],
created_at: NOW - 7_200,
});
const { patchedUserBodies } = await installAdminMock(page, {
users: [pendingUser, makeUser({ user_id: "old-1", display_name: "기존 학습자" })],
});
await page.goto("/admin/users");
// 대기 1건이 탭 라벨과 요약 카운트에 함께 보인다.
await expect(page.getByRole("tab", { name: "가입 승인 1" })).toHaveAttribute(
"aria-selected",
"true",
);
const metrics = page.locator(".vgops-approval-metrics span");
await expect(metrics.nth(0)).toContainText("1");
await expect(metrics.nth(0)).toContainText("대기");
const card = page.locator(".vgops-approval").filter({ hasText: "new-learner@hs.ac.kr" });
await expect(card).toContainText("승인 대기");
await expect(card).toContainText("상담학과");
await expect(card).toContainText("cohort-2026a");
const patchPromise = page.waitForRequest(
(request) =>
request.method() === "PATCH" && request.url().includes("/admin/users/signup-1"),
);
await card.getByRole("button", { name: "승인" }).click();
const patch = await patchPromise;
expect(patch.postDataJSON()).toMatchObject({ account_status: "approved" });
// 승인되면 큐가 비고 승인 카운트가 늘어난다.
await expect(page.getByText("처리할 가입 요청이 없습니다")).toBeVisible();
await expect(metrics.nth(0)).toContainText("0");
await expect(metrics.nth(1)).toContainText("2");
await expect(page.getByRole("tab", { name: "가입 승인", exact: true })).toBeVisible();
});
// usecase: 관리자가 의심스러운 가입 요청을 보류(정지) 처리한다
test("가입 승인 큐에서 가입 요청을 보류하면 보류 카운트로 이동한다", async ({ page }) => {
const { patchedUserBodies } = await installAdminMock(page, {
users: [
makeUser({
user_id: "signup-a",
email: "keep@hs.ac.kr",
display_name: "정상 가입",
account_status: "pending",
}),
makeUser({
user_id: "signup-b",
email: "suspicious@hs.ac.kr",
display_name: "의심 가입",
account_status: "pending",
}),
],
});
await page.goto("/admin/users");
const metrics = page.locator(".vgops-approval-metrics span");
await expect(metrics.nth(0)).toContainText("2");
const suspicious = page
.locator(".vgops-approval")
.filter({ hasText: "suspicious@hs.ac.kr" });
const patchPromise = page.waitForRequest(
(request) =>
request.method() === "PATCH" && request.url().includes("/admin/users/signup-b"),
);
await suspicious.getByRole("button", { name: "보류" }).click();
const patch = await patchPromise;
expect(patch.postDataJSON()).toMatchObject({ account_status: "suspended" });
expect(patchedUserBodies).toHaveLength(1);
// 보류된 계정은 큐에서 내려가고 나머지 대기 건만 남는다.
await expect(suspicious).toHaveCount(0);
await expect(
page.locator(".vgops-approval").filter({ hasText: "keep@hs.ac.kr" }),
).toBeVisible();
await expect(metrics.nth(0)).toContainText("1");
await expect(metrics.nth(2)).toContainText("1");
});
// usecase: 관리자가 티켓 큐에서 접수 상세(신고자·담당·이력·출처)를 열람한다
test("티켓 카드에서 신고자, 담당 그룹, 처리 이력, 출처 경로를 상세 열람한다", async ({
page,
}) => {
await installAdminMock(page, {
tickets: [
makeTicket({
ticket_id: "ticket-detail-1",
subject: "세션 리뷰가 저장되지 않음",
body: "리뷰 화면에서 저장을 눌러도 결과가 사라집니다.",
status: "in_progress",
priority: "high",
category: "session_review",
assigned_group: "서비스 운영자",
event_count: 3,
source_path: "/review/123",
duplicate_count: 2,
duplicate_parent_candidate_id: "ticket-parent-0",
child_ticket_count: 1,
}),
],
});
await page.goto("/admin/tickets");
const card = page
.locator(".vgops-ticket")
.filter({ hasText: "세션 리뷰가 저장되지 않음" });
await expect(card).toBeVisible();
await expect(card).toContainText("리뷰 화면에서 저장을 눌러도 결과가 사라집니다.");
const meta = card.locator(".vgops-ticket__meta");
await expect(meta).toContainText("세션/리뷰");
await expect(meta).toContainText("learner1@hs.ac.kr");
await expect(meta).toContainText("담당 서비스 운영자");
await expect(meta).toContainText("처리 이력 3건");
await expect(card).toContainText("중복 후보 2건");
await expect(card).toContainText("하위 티켓 1건");
await expect(card.locator("small")).toContainText("/review/123");
await expect(card).toContainText("높음");
await expect(card).toContainText("처리 중");
// 이미 처리 중인 티켓은 "처리 중" 전환 버튼이 비활성으로 잠긴다.
await expect(card.getByRole("button", { name: "처리 중" })).toBeDisabled();
});
// usecase: 관리자가 티켓을 해결 처리해 최근 해결 이력으로 내린다
test("티켓 해결 버튼으로 해결 처리하면 최근 해결 이력으로 이동한다", async ({ page }) => {
const { patchedTicketBodies } = await installAdminMock(page, {
tickets: [
makeTicket({
ticket_id: "ticket-resolve-1",
subject: "음성 입력이 브라우저에서 멈춤",
category: "voice_browser",
status: "open",
}),
],
});
await page.goto("/admin/tickets");
const card = page
.locator(".vgops-ticket")
.filter({ hasText: "음성 입력이 브라우저에서 멈춤" });
await expect(card).toBeVisible();
const patchPromise = page.waitForRequest(
(request) =>
request.method() === "PATCH" &&
request.url().includes("/admin/tickets/ticket-resolve-1"),
);
await card.getByRole("button", { name: "해결" }).click();
const patch = await patchPromise;
expect(patch.postDataJSON()).toMatchObject({ status: "resolved" });
expect(patchedTicketBodies).toHaveLength(1);
// 재조회 후 미해결 큐는 비고 해결 이력 섹션에 같은 제목이 남는다.
await expect(page.getByText("미해결 운영 티켓이 없습니다")).toBeVisible();
await expect(page.getByRole("heading", { name: "최근 해결 이력" })).toBeVisible();
const history = page
.locator(".vgops-ticket--history")
.filter({ hasText: "음성 입력이 브라우저에서 멈춤" });
await expect(history).toBeVisible();
await expect(history).toContainText("해결");
});
// usecase: 관리자가 해결 처리하면서 학습자에게 보일 해결 노트를 작성하려 한다
test("티켓 해결 시 해결 노트를 작성할 입력 수단이 제공된다", async ({ page }) => {
// 근거: API 계약(AdminSupportTicketResponse.resolution_note, PATCH의
// resolution_note?: string|null)과 학습자 Settings.tsx가 resolution_note를
// 표시한다. 그러나 관리자 큐(Admin.tsx TicketActions)에는 노트 입력이 없다 —
// 실패하면 제품 결함 의심으로 남긴다.
await installAdminMock(page, {
tickets: [
makeTicket({
ticket_id: "ticket-note-1",
subject: "계정 권한이 갑자기 사라짐",
category: "account_access",
status: "in_progress",
}),
],
});
await page.goto("/admin/tickets");
const card = page
.locator(".vgops-ticket")
.filter({ hasText: "계정 권한이 갑자기 사라짐" });
await expect(card).toBeVisible();
// 해결 전 노트를 남길 수 있는 입력(텍스트박스)이 카드 안에 있어야 한다.
await expect(card.getByRole("textbox", { name: /해결 노트|resolution/i })).toBeVisible();
});
// usecase: 관리자가 AI 엔진 설정에서 게이트웨이 검증 실패를 fail-closed로 확인한다
test("게이트웨이 모델 검증이 실패하면 저장이 잠기고 경고가 표시된다", async ({ page }) => {
await installAdminMock(page, {
capabilitiesAvailable: false,
capabilitiesDetail: "게이트웨이에 연결할 수 없어 모델 검증에 실패했습니다.",
});
await page.goto("/admin/ai");
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
// fail-closed: 검증 불가 상태가 role=alert로 노출된다.
const capabilityBox = page.locator(".aic-capability");
await expect(capabilityBox).toHaveAttribute("role", "alert");
await expect(capabilityBox).toContainText("모델 목록을 확인할 수 없음");
await expect(capabilityBox).toContainText(
"게이트웨이에 연결할 수 없어 모델 검증에 실패했습니다.",
);
// 모델 선택은 잠기고 저장된 모델은 "확인되지 않음"으로 명시된다.
await expect(page.getByLabel("AI 기본 모델")).toBeDisabled();
await expect(page.getByLabel("AI 기본 모델")).toContainText(
"gateway-default · 현재 목록에서 확인되지 않음",
);
// 저장 버튼도 닫힌다(검증 없는 설정 저장 불가).
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled();
});
// usecase: 관리자가 연결 주소를 바꾸면 저장 전에 재검증 확인 단계를 거친다
test("연결 주소 변경 후 목록 재확인을 거쳐야 저장이 다시 열린다", async ({ page }) => {
await installAdminMock(page);
await page.goto("/admin/ai");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeEnabled();
// 주소를 바꾸는 즉시 기존 검증이 무효화되고 재확인을 요구한다.
await page.getByLabel("AI 연결 주소").fill("http://127.0.0.1:9199");
const capabilityBox = page.locator(".aic-capability");
await expect(capabilityBox).toContainText(
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
);
await expect(capabilityBox).toHaveAttribute("role", "alert");
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled();
// 새 주소로 목록을 재조회해야 저장이 다시 열린다.
const catalogRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
url.pathname.endsWith("/admin/engine-capabilities") &&
url.searchParams.get("engine_url") === "http://127.0.0.1:9199"
);
});
await page.getByRole("button", { name: "목록 새로고침" }).click();
await catalogRequest;
await expect(capabilityBox).toContainText("1개 모델 확인됨");
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeEnabled();
});
// usecase: 관리자가 운영 홈에서 서비스 헬스 이벤트와 지표를 읽는다
test("운영 홈에서 제한 운영 서비스와 가용성 지표를 확인한다", async ({ page }) => {
await installAdminMock(page, {
healthStatus: "degraded",
engineMode: "claude_cli",
services: [
{
key: "engine",
name: "AI 엔진",
status: "ok",
detail: "게이트웨이 응답 정상",
metric: "240ms",
load: 0.24,
},
{
key: "db",
name: "데이터베이스",
status: "ok",
detail: "커넥션 풀 정상",
metric: "풀 3/10",
load: 0.3,
},
{
key: "evaluation",
name: "평가 파이프라인",
status: "degraded",
detail: "평가 대기열이 지연되고 있습니다",
metric: "대기 12건",
load: 0.82,
},
],
uptime: { sample_count: 288, ok_ratio: 0.996, last_down_at: NOW - 200_000 },
});
await page.goto("/admin");
// 상태 배너: degraded 문구와 환경·엔진 모드 라벨.
const status = page.locator(".vgops-status");
await expect(status).toContainText("일부 서비스가 제한된 상태입니다.");
await expect(status).toContainText("개발 · Claude CLI 게이트웨이");
// KPI: 정상 2/3과 점검 1건.
const healthKpi = page.locator(".vgops-kpi").filter({ hasText: "서비스 헬스" });
await expect(healthKpi).toContainText("2/3");
await expect(healthKpi).toContainText("1개 점검");
// 서비스 카드: 각 서비스의 상태 배지·상세·지표.
const cards = page.locator(".vgops-service:not(.vgops-service--skeleton)");
await expect(cards).toHaveCount(3);
const degradedCard = cards.filter({ hasText: "평가 파이프라인" });
await expect(degradedCard).toContainText("제한 운영");
await expect(degradedCard).toContainText("평가 대기열이 지연되고 있습니다");
await expect(degradedCard).toContainText("대기 12건");
await expect(cards.filter({ hasText: "AI 엔진" })).toContainText("240ms");
// 가용성 패널: 업타임 표본 정상률과 최근 중단 기록.
const availability = page.locator(".vgops-panel").filter({ hasText: "가용성" });
await expect(availability).toContainText("샘플 정상률");
await expect(availability).toContainText("99.6%");
await expect(availability).toContainText("제한 운영");
});
// usecase: 관리자가 G8 게이트 카드에서 승인이 막힌 이유를 상시 확인한다
test("G8 게이트의 승인 차단 사유가 조작 없이도 상시 노출된다", async ({ page }) => {
await installCiMock(page);
await page.goto("/admin/continuous-improvement");
await expect(
page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" }),
).toBeVisible();
// 증거 2종뿐인 모델 게이트: 이유가 문장으로 노출되고 버튼 라벨도 잠금 상태를 말한다.
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
await expect(
modelGate.getByText("필수 증거 4종이 모두 있어야 기록 가능"),
).toBeVisible();
await expect(modelGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
// 증거 4종 완비 릴리스 게이트: 사유 미입력이 차단 이유로 노출된다.
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
await expect(
releaseGate.getByText("승인 사유를 먼저 입력해야 함"),
).toBeVisible();
const submitButton = releaseGate.getByRole("button", { name: "사람 승인 기록" });
await expect(submitButton).toBeDisabled();
// 차단 사유는 입력·버튼과 aria-describedby로 연결되어 보조기술에도 상시 전달된다.
const requirementId = `approval-requirement-${RELEASE_GATE_ID}`;
await expect(releaseGate.getByLabel("사람 승인 사유")).toHaveAttribute(
"aria-describedby",
requirementId,
);
await expect(submitButton).toHaveAttribute("aria-describedby", requirementId);
// 사유를 입력하면 준비 문구로, 지우면 다시 차단 사유로 살아있게 갱신된다.
const reason = releaseGate.getByLabel("사람 승인 사유");
await reason.fill("릴리스 증거 4종을 독립 검토함");
await expect(releaseGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible();
await expect(submitButton).toBeEnabled();
await reason.fill("");
await expect(releaseGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
await expect(submitButton).toBeDisabled();
});
// usecase: 관리자가 마우스 없이 사유 입력 후 Enter로 릴리스 승격을 원장에 기록한다
test("릴리스 게이트 승인 사유를 키보드 Enter로 제출해 원장에 기록한다", async ({
page,
}) => {
const mock = await installCiMock(page);
await page.goto("/admin/continuous-improvement");
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
await expect(releaseGate).toContainText("릴리스 uc-g8-release-2026-08-17");
await expect(releaseGate).toContainText("승격 후보");
const reason = releaseGate.getByLabel("사람 승인 사유");
await reason.fill("기준선·임계값·출처·롤백 증거를 모두 확인함");
await reason.press("Enter");
await expect(releaseGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
expect(mock.approvalBodies).toHaveLength(1);
expect(mock.approvalBodies[0]).toMatchObject({
target_kind: "release_gate",
target_id: RELEASE_GATE_ID,
decision: "approve_promotion",
reason_code: "기준선·임계값·출처·롤백 증거를 모두 확인함",
});
expect(mock.approvalBodies[0].evidence_refs).toHaveLength(4);
// 기록 후 입력 폼은 닫히고 다시 제출할 수 없다(append-only).
await expect(releaseGate.getByLabel("사람 승인 사유")).toHaveCount(0);
});
// usecase: 관리자가 슈퍼 관리자 키워드로 특정 권한 보유자를 찾아낸다
test("사용자 목록에서 슈퍼 키워드 검색으로 슈퍼 관리자만 남긴다", async ({ page }) => {
await installAdminMock(page, {
users: [
makeUser({
user_id: "user-learner",
email: "lee@hs.ac.kr",
display_name: "이서연",
affiliation: "간호학과",
cohort_ids: ["cohort-2026a"],
}),
makeUser({
user_id: "user-teacher",
email: "park@hs.ac.kr",
display_name: "박교수",
role: "teacher",
affiliation: "상담학과",
}),
makeUser({
user_id: "user-super",
email: "kim-ops@twentyoz.kr",
display_name: "김운영",
role: "admin",
admin_access: true,
super_admin: true,
affiliation: "운영팀",
}),
],
});
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
const rows = page.locator(".vgops-user-table tbody tr");
await expect(rows).toHaveCount(3);
await expect(page.locator(".vgops-users-toolbar")).toContainText("3 / 3명");
// super_admin 플래그는 "슈퍼 관리자" 파생 키워드로 검색된다.
await page.getByLabel("사용자 검색").fill("슈퍼");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("kim-ops@twentyoz.kr");
await expect(rows.first()).toContainText("슈퍼");
await expect(page.locator(".vgops-users-toolbar")).toContainText("1 / 3명");
});
// usecase: 관리자가 소속(학과)으로 사용자를 좁혀 확인한다
test("사용자 목록에서 소속 학과명으로 검색해 해당 학과만 남긴다", async ({ page }) => {
await installAdminMock(page, {
users: [
makeUser({
user_id: "user-nursing",
email: "lee@hs.ac.kr",
display_name: "이서연",
affiliation: "간호학과",
}),
makeUser({
user_id: "user-counsel-1",
email: "park@hs.ac.kr",
display_name: "박교수",
role: "teacher",
affiliation: "상담학과",
}),
makeUser({
user_id: "user-counsel-2",
email: "choi@hs.ac.kr",
display_name: "최학생",
affiliation: "상담학과",
}),
],
});
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
const rows = page.locator(".vgops-user-table tbody tr");
await expect(rows).toHaveCount(3);
await page.getByLabel("사용자 검색").fill("상담학과");
await expect(rows).toHaveCount(2);
await expect(rows.filter({ hasText: "park@hs.ac.kr" })).toHaveCount(1);
await expect(rows.filter({ hasText: "choi@hs.ac.kr" })).toHaveCount(1);
await expect(page.locator(".vgops-users-toolbar")).toContainText("2 / 3명");
// 검색을 지우면 전체가 복원된다.
await page.getByLabel("사용자 검색").fill("");
await expect(rows).toHaveCount(3);
});
});