개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue