개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -39,6 +39,7 @@ interface FixtureUser {
display_name: string;
role: "learner" | "teacher" | "admin";
admin_access: boolean;
learner_feedback_enabled: boolean;
super_admin: boolean;
account_status: "pending" | "approved" | "suspended";
affiliation: string;
@ -55,6 +56,7 @@ function makeUser(overrides: Partial<FixtureUser> & { user_id: string }): Fixtur
display_name: overrides.user_id,
role: "learner",
admin_access: false,
learner_feedback_enabled: true,
super_admin: false,
account_status: "approved",
affiliation: "",
@ -90,6 +92,43 @@ interface FixtureTicket {
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 {
@ -140,6 +179,8 @@ function ticketsSummary(tickets: FixtureTicket[]) {
interface AdminMockOptions {
users?: FixtureUser[];
tickets?: FixtureTicket[];
protocols?: FixtureProtocol[];
protocolActivationFailures?: number;
services?: Array<{
key: string;
name: string;
@ -164,6 +205,10 @@ interface AdminMockOptions {
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>> = [];
@ -218,6 +263,101 @@ async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
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>;
@ -369,7 +509,12 @@ async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
await fulfill({ detail: `unmocked API request: ${method} ${path}` }, 404);
});
return { patchedUserBodies, patchedTicketBodies };
return {
createdUserBodies,
createdProtocolBodies,
patchedUserBodies,
patchedTicketBodies,
};
}
/** G8 지속 개선 콕핏 fixture — 모델 게이트는 증거 2종만, 릴리스 게이트는 4종 완비. */
@ -1041,4 +1186,260 @@ test.describe("관리자 콘솔 유스케이스 여정", () => {
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);
});
});