vignette/apps/web/e2e/uc-admin-console.spec.ts

1521 lines
57 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 { mkdir } from "node:fs/promises";
import path from "node:path";
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;
learner_feedback_enabled: 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,
learner_feedback_enabled: true,
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;
}
interface FixtureProtocol {
protocol_id: string;
source_id: string;
title: string;
source: string;
version: number;
license: "A" | "B" | "C" | "D";
external_llm_ok: boolean;
content: string;
content_hash: string;
status: "draft" | "active" | "retired";
registered_by: string;
registered_at: string;
activated_at: string | null;
retired_at: string | null;
}
function makeProtocol(
overrides: Partial<FixtureProtocol> & { protocol_id: string; title: string },
): FixtureProtocol {
return {
source_id: `protocol:${overrides.protocol_id}`,
source: "https://example.edu/protocols/default",
version: 1,
license: "B",
external_llm_ok: false,
content: "위험도를 먼저 확인한다.",
content_hash: "a".repeat(64),
status: "draft",
registered_by: "uc-admin-1",
registered_at: CREATED_AT,
activated_at: null,
retired_at: null,
...overrides,
};
}
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[];
protocols?: FixtureProtocol[];
protocolActivationFailures?: number;
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 ?? [];
let protocols = options.protocols ?? [];
let protocolActivationFailures = options.protocolActivationFailures ?? 0;
const createdUserBodies: Array<Record<string, unknown>> = [];
const createdProtocolBodies: Array<Record<string, unknown>> = [];
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;
}
if (method === "GET" && path.endsWith("/admin/protocols")) {
await fulfill({ protocols, total: protocols.length });
return;
}
if (method === "POST" && path.endsWith("/admin/protocols")) {
const body = request.postDataJSON() as Record<string, unknown>;
createdProtocolBodies.push(body);
const protocolId = `71000000-0000-4000-8000-${String(
createdProtocolBodies.length,
).padStart(12, "0")}`;
const created = makeProtocol({
protocol_id: protocolId,
title: String(body.title ?? ""),
source: String(body.source ?? ""),
version: Number(body.version ?? 1),
license: (body.license as FixtureProtocol["license"] | undefined) ?? "B",
external_llm_ok: body.external_llm_ok === true,
content: String(body.content ?? ""),
content_hash: "b".repeat(64),
});
protocols = [created, ...protocols];
await fulfill(created, 201);
return;
}
const protocolAction = /\/admin\/protocols\/([^/]+)\/(activate|retire)$/.exec(path);
if (method === "POST" && protocolAction) {
const [, protocolId, action] = protocolAction;
const index = protocols.findIndex((item) => item.protocol_id === protocolId);
if (index === -1) {
await fulfill({ detail: "unknown protocol" }, 404);
return;
}
if (action === "activate") {
if (protocolActivationFailures > 0) {
protocolActivationFailures -= 1;
await fulfill({ detail: "vector unavailable" }, 503);
return;
}
if (protocols[index].status !== "draft") {
await fulfill({ detail: "draft required" }, 409);
return;
}
protocols[index] = {
...protocols[index],
status: "active",
activated_at: CREATED_AT,
};
await fulfill({
protocol: protocols[index],
chunks_indexed: 2,
skipped_unchanged: false,
embedded: true,
degraded: false,
});
return;
}
if (protocols[index].status !== "active") {
await fulfill({ detail: "active required" }, 409);
return;
}
protocols[index] = {
...protocols[index],
status: "retired",
retired_at: CREATED_AT,
};
await fulfill(protocols[index]);
return;
}
if (method === "POST" && path.endsWith("/admin/users")) {
const body = request.postDataJSON() as Record<string, unknown>;
createdUserBodies.push(body);
const created = makeUser({
user_id: `preregistered-${createdUserBodies.length}`,
email: String(body.email ?? ""),
display_name: String(body.display_name ?? ""),
role: (body.role as FixtureUser["role"] | undefined) ?? "learner",
admin_access: Boolean(body.admin_access),
learner_feedback_enabled: body.learner_feedback_enabled !== false,
account_status:
(body.account_status as FixtureUser["account_status"] | undefined) ?? "pending",
affiliation: String(body.affiliation ?? ""),
cohort_ids: Array.isArray(body.cohort_ids)
? body.cohort_ids.map((value) => String(value))
: [],
created_at: NOW,
last_seen_at: NOW,
});
users.unshift(created);
await fulfill(created, 201);
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 {
createdUserBodies,
createdProtocolBodies,
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);
});
// usecase: 관리자가 외부 연구참여자를 exact-email로 사전등록하고 별도로 승인한다
test("외부 연구참여자 사전등록은 pending으로 생성되고 승인 큐를 거친다", async ({
page,
}) => {
const { createdUserBodies, patchedUserBodies } = await installAdminMock(page, {
users: [],
});
await page.goto("/admin/users");
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
await expect(
page.getByRole("heading", { name: "외부 연구참여자 사전등록" }),
).toBeVisible();
await expect(page.getByText("정확한 이메일 주소로만 Google·개발 로그인을 통과"))
.toBeVisible();
const fixedApprovalStatus = page.getByLabel("새 사용자 승인 상태");
await expect(fixedApprovalStatus).toHaveAttribute("data-value", "pending");
await expect(fixedApprovalStatus).toHaveText("승인 대기");
await expect(
page.getByRole("combobox", { name: "새 사용자 승인 상태" }),
).toHaveCount(0);
const exactEmail = "participant.exact@gmail.com";
await page.getByLabel("새 사용자 이메일").fill(exactEmail);
await page.getByLabel("새 사용자 표시 이름").fill("외부 연구참여자");
await page.getByLabel("새 사용자 코호트").fill("external-study-2026");
const createRequest = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname.endsWith("/admin/users"),
);
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
const request = await createRequest;
expect(request.postDataJSON()).toMatchObject({
email: exactEmail,
display_name: "외부 연구참여자",
role: "learner",
account_status: "pending",
cohort_ids: ["external-study-2026"],
});
expect(createdUserBodies).toHaveLength(1);
await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
await expect(page.getByLabel("새 사용자 승인 상태")).toHaveAttribute(
"data-value",
"pending",
);
const secondEmail = "participant.second@outlook.com";
await page.getByLabel("새 사용자 이메일").fill(secondEmail);
await page.getByLabel("새 사용자 표시 이름").fill("두 번째 연구참여자");
const secondCreateRequest = page.waitForRequest(
(nextRequest) =>
nextRequest.method() === "POST" &&
new URL(nextRequest.url()).pathname.endsWith("/admin/users"),
);
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
const secondRequest = await secondCreateRequest;
expect(secondRequest.postDataJSON()).toMatchObject({
email: secondEmail,
display_name: "두 번째 연구참여자",
role: "learner",
account_status: "pending",
});
expect(createdUserBodies).toHaveLength(2);
expect(createdUserBodies.every((body) => body.account_status === "pending")).toBe(true);
await page.getByRole("tab", { name: "가입 승인 2" }).click();
const card = page.locator(".vgops-approval").filter({ hasText: exactEmail });
await expect(card).toContainText("승인 대기");
await card.getByRole("button", { name: "승인" }).click();
const secondCard = page.locator(".vgops-approval").filter({ hasText: secondEmail });
await expect(secondCard).toContainText("승인 대기");
await secondCard.getByRole("button", { name: "승인" }).click();
expect(patchedUserBodies).toContainEqual({
user_id: "preregistered-1",
account_status: "approved",
});
expect(patchedUserBodies).toContainEqual({
user_id: "preregistered-2",
account_status: "approved",
});
await expect(page.getByText("처리할 가입 요청이 없습니다")).toBeVisible();
});
test("사용자별 학습자 AI 피드백을 끄고 관리자 API에 저장한다", async ({ page }) => {
const user = makeUser({
user_id: "feedback-policy-user",
email: "feedback-policy@hs.ac.kr",
display_name: "피드백 정책 대상",
});
const { patchedUserBodies } = await installAdminMock(page, { users: [user] });
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).click();
const row = page
.locator(".vgops-user-table tbody tr")
.filter({ hasText: user.email });
const tableScroll = page.locator(".vgops-user-table-scroll");
await expect
.poll(() =>
tableScroll.evaluate(
(element) => element.scrollWidth > element.clientWidth,
),
)
.toBe(true);
const toggle = row.getByLabel(`${user.email} 학습자 AI 피드백`);
await expect(toggle).toBeChecked();
await toggle.focus();
await toggle.press("Space");
await expect(toggle).not.toBeChecked();
const patchRequest = page.waitForRequest(
(request) =>
request.method() === "PATCH" &&
request.url().includes(`/admin/users/${user.user_id}`),
);
await row.getByRole("button", { name: "저장" }).click();
const request = await patchRequest;
expect(request.postDataJSON()).toMatchObject({ learner_feedback_enabled: false });
expect(patchedUserBodies.at(-1)).toMatchObject({
user_id: user.user_id,
learner_feedback_enabled: false,
});
await expect(toggle).not.toBeChecked();
});
test("상담 프로토콜을 초안 등록하고 활성화한 뒤 퇴역한다", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const { createdProtocolBodies } = await installAdminMock(page, { protocols: [] });
await page.goto("/admin/access");
const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" });
await protocolTab.focus();
await protocolTab.press("Enter");
await expect(page.getByRole("heading", { name: "상담 프로토콜 초안 등록" }))
.toBeVisible();
const title = "초기면담 위기 확인 프로토콜";
await page.getByLabel("프로토콜 제목").fill(title);
await page
.getByLabel("프로토콜 출처")
.fill("https://example.edu/protocols/intake-v2");
await page.getByLabel("프로토콜 버전").fill("0");
await page.getByLabel("프로토콜 라이선스").selectOption("B");
await page.getByLabel("외부 LLM 사용 허용").check();
await page
.getByLabel("프로토콜 원문")
.fill("자해 위험과 즉시 안전 여부를 먼저 확인한다.");
await page.getByRole("button", { name: "초안 등록", exact: true }).click();
await expect(page.getByRole("alert")).toContainText(
"버전은 1부터 1,000,000 사이의 정수여야 합니다.",
);
expect(createdProtocolBodies).toHaveLength(0);
await page.getByLabel("프로토콜 버전").fill("2");
const createRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname.endsWith("/admin/protocols"),
);
await page.getByRole("button", { name: "초안 등록", exact: true }).click();
const request = await createRequest;
expect(request.postDataJSON()).toMatchObject({
title,
version: 2,
license: "B",
external_llm_ok: true,
});
expect(createdProtocolBodies).toHaveLength(1);
const card = page.locator(".vgops-protocol-card").filter({ hasText: title });
await expect(card).toContainText("초안");
const activate = card.getByRole("button", { name: `${title} 활성화` });
await activate.focus();
await activate.press("Enter");
await expect(card).toContainText("활성");
const retire = card.getByRole("button", { name: `${title} 퇴역` });
await retire.focus();
await retire.press("Enter");
await expect(card).toContainText("퇴역");
await expect(card.getByText("변경 불가")).toBeVisible();
});
test("모바일에서 C/D 외부 사용을 차단하고 활성화 오류를 복구한다", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 568 });
const protocol = makeProtocol({
protocol_id: "71000000-0000-4000-8000-000000000099",
title: "모바일 복구 프로토콜",
license: "A",
external_llm_ok: true,
});
await installAdminMock(page, {
protocols: [protocol],
protocolActivationFailures: 1,
});
await page.goto("/admin/access");
const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" });
await protocolTab.click();
await expect(protocolTab).toHaveAttribute("aria-selected", "true");
await page.getByLabel("프로토콜 라이선스").selectOption("C");
const externalToggle = page.getByLabel("외부 LLM 사용 허용");
await expect(externalToggle).toBeDisabled();
await expect(externalToggle).not.toBeChecked();
await expect(page.getByText("라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."))
.toBeVisible();
const card = page.locator(".vgops-protocol-card").filter({
hasText: protocol.title,
});
const activate = card.getByRole("button", { name: `${protocol.title} 활성화` });
await activate.click();
await expect(page.getByRole("alert")).toContainText("vector unavailable");
await activate.click();
await expect(card).toContainText("활성");
await expect(page.getByRole("alert")).toHaveCount(0);
const protocolRegion = page.locator(".vgops-protocols");
await expect
.poll(() =>
protocolRegion.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
)
.toBe(true);
await expect
.poll(() =>
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
)
.toBe(true);
const touchTargets = page.locator(
".vgops-protocols button, .vgops-protocols input:not([type=checkbox]), " +
".vgops-protocols select, .vgops-protocols textarea, .vgops-protocol-check",
);
const targetCount = await touchTargets.count();
expect(targetCount).toBeGreaterThan(0);
for (let index = 0; index < targetCount; index += 1) {
const box = await touchTargets.nth(index).boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
});
test("모바일 티켓 새로고침과 해결 처리가 동작한다 @single-run", async ({
page,
}) => {
const outputDir = path.resolve(
process.cwd(),
"../../outputs/ux-audit-2026-09-12/admin-ticket-targets",
);
await mkdir(outputDir, { recursive: true });
await page.setViewportSize({ width: 320, height: 844 });
await installAdminMock(page, {
tickets: [
makeTicket({
ticket_id: "ticket-mobile-active",
subject: "모바일 티켓 처리",
status: "open",
}),
makeTicket({
ticket_id: "ticket-mobile-history",
subject: "해결된 모바일 티켓",
status: "resolved",
resolved_at: NOW - 600,
}),
],
});
await page.goto("/admin/tickets");
const refresh = page
.locator('[data-admin-section="tickets"] .vgops-head')
.getByRole("button", { name: "새로고침" });
const activeCard = page.locator(".vgops-ticket").filter({
hasText: "모바일 티켓 처리",
});
await expect(refresh).toBeVisible();
await expect(activeCard).toBeVisible();
await page.screenshot({ path: path.join(outputDir, "tickets-top-320.png") });
const refreshBox = await refresh.boundingBox();
expect(refreshBox?.width ?? 0).toBeGreaterThanOrEqual(44);
expect(refreshBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await page.setViewportSize({ width: 710, height: 844 });
const refreshAt720 = await refresh.boundingBox();
expect(refreshAt720?.width ?? 0).toBeGreaterThanOrEqual(44);
expect(refreshAt720?.height ?? 0).toBeGreaterThanOrEqual(44);
await page.setViewportSize({ width: 320, height: 844 });
const reload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
new URL(response.url()).pathname.endsWith("/admin/tickets"),
);
await refresh.click();
await reload;
await expect(activeCard).toBeVisible();
const patch = page.waitForRequest(
(request) =>
request.method() === "PATCH" &&
request.url().includes("/admin/tickets/ticket-mobile-active"),
);
await activeCard.getByRole("button", { name: "해결" }).click();
await patch;
const history = page.locator(".vgops-ticket--history").filter({
hasText: "모바일 티켓 처리",
});
await expect(history).toBeVisible();
await history.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(outputDir, "tickets-history-320.png") });
await expect
.poll(() =>
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
)
.toBe(true);
});
});