현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -39,6 +39,35 @@ interface AdminUsersResponse {
users: AdminManagedUser[];
}
interface AdminUsageBreakdown {
provider: string;
model: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded";
}
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
window_days: number;
total_turns: number;
metered_turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
budget: AdminUsageBudget;
by_provider: AdminUsageBreakdown[];
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
@ -66,6 +95,11 @@ function isAdminUsersResponse(response: Response) {
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
}
function isAdminUsageResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage");
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
@ -106,17 +140,33 @@ function engineModeLabel(value: string) {
return value;
}
function countLabel(value: number) {
if (!Number.isFinite(value)) return "0";
return Math.round(value).toLocaleString("ko-KR");
}
function costLabel(value: number) {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
await page.goto("/admin");
const healthResponse = await healthResponsePromise;
const [healthResponse, usageResponse] = await Promise.all([
healthResponsePromise,
usageResponsePromise,
]);
await expectResponseOk(healthResponse);
await expectResponseOk(usageResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
const usage = (await usageResponse.json()) as AdminUsageResponse;
expect(health.services.length).toBeGreaterThan(0);
return health;
return { health, usage };
}
async function openAdminAndReadUsers(page: Page) {
@ -225,7 +275,7 @@ test.describe("admin route", () => {
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const health = await openAdminAndReadHealth(page);
const { health, usage } = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
@ -242,6 +292,34 @@ test.describe("admin route", () => {
String(counts.degraded),
String(counts.down),
]);
await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible();
await expect(page.locator(".ad-usage-kpi b")).toHaveText([
costLabel(usage.cost_usd),
countLabel(usage.tokens_in),
countLabel(usage.tokens_out),
usage.total_turns > 0
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
: "0%",
]);
await expect(page.locator(".ad-usage-budget")).toContainText(
usage.budget.status === "disabled"
? "예산 경고 비활성"
: usage.budget.status === "exceeded"
? "예산 초과"
: usage.budget.status === "warn"
? "예산 주의"
: "예산 정상",
);
if (usage.by_provider.length > 0) {
await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length);
await expect(page.locator(".ad-usage-row").first()).toContainText(
usage.by_provider[0].provider,
);
} else {
await expect(page.locator(".ad-usage-breakdown")).toContainText(
"최근 윈도우에 계량된 AI 턴이 없습니다.",
);
}
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {

View file

@ -69,10 +69,8 @@ test.describe("auth domain policy", () => {
const googleButtons = page.locator(".lg-obtn");
await expect(googleButtons).toHaveCount(2);
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
const currentHost = new URL(page.url()).hostname;
const redirectHost = new URL(config.redirect_uri).hostname;
const localOAuthUnavailable =
isLocalHostname(currentHost) &&
const devOAuthUnavailable =
config.dev_login_enabled &&
!isLocalHostname(redirectHost);
if (config.dev_login_enabled) {
@ -81,7 +79,7 @@ test.describe("auth domain policy", () => {
await expect(page.locator(".lg-dev")).toHaveCount(0);
}
if (config.google_oauth_configured && !localOAuthUnavailable) {
if (config.google_oauth_configured && !devOAuthUnavailable) {
await expect(googleButtons.first()).toBeEnabled();
await expect(page.locator(".lg-config")).toHaveCount(0);
} else {
@ -91,8 +89,11 @@ test.describe("auth domain policy", () => {
await expect(page).toHaveURL(/\/login$/);
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
if (localOAuthUnavailable) {
if (devOAuthUnavailable) {
await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인");
await page.goto("/api/auth/login?provider=google&next=%2Flearn");
await expect(page).toHaveURL(/\/login\?oauth=local_oauth_unavailable$/);
await expect(page.locator(".lg-error")).toContainText("로컬 개발 주소에서는 Google OAuth");
return;
}
@ -103,6 +104,14 @@ test.describe("auth domain policy", () => {
}
});
test("shows the concrete OAuth failure reason on the login screen", async ({ page }) => {
await page.goto("/login?oauth=provider_error");
const error = page.locator(".lg-error");
await expect(error).toContainText("Google이 인증 코드를 발급하지 못했습니다");
await expect(error).toContainText("오류 코드: provider_error");
});
test("logs in locally with the server dev session and redirects to learner home", async ({
page,
}) => {