1521 lines
57 KiB
TypeScript
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);
|
|
});
|
|
});
|