전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -82,6 +82,7 @@ interface AdminUsageEvaluatorCache {
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
generated_at: number;
window_days: number;
total_turns: number;
metered_turns: number;
@ -94,6 +95,16 @@ interface AdminUsageResponse {
daily_cost?: AdminUsageDailyCost[];
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
source: "database" | "runtime_cache" | "runtime_default";
durable: boolean;
updated_by: string | null;
updated_at: number | null;
}
interface AdminUptimeResponse {
source: "database" | "unavailable";
durable: boolean;
@ -161,6 +172,8 @@ async function mockAdminSession(
options: {
adminUsers?: unknown[];
adminTickets?: unknown;
adminUsage?: AdminUsageResponse;
engineConfig?: AdminEngineConfigResponse;
} = {},
) {
const seenAdminEndpoints = new Set<string>();
@ -221,33 +234,70 @@ async function mockAdminSession(
if (method === "GET" && path.endsWith("/admin/usage")) {
seenAdminEndpoints.add("usage");
await fulfillJson(
options.adminUsage ?? {
source: "database",
durable: true,
generated_at: 1_783_990_800,
window_days: Number(url.searchParams.get("window_days") ?? 7),
total_turns: 0,
metered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
budget: {
limit_usd: 0,
used_ratio: 0,
remaining_usd: null,
status: "disabled",
},
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [],
daily_cost: [],
},
);
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
seenAdminEndpoints.add("engine-config");
await fulfillJson(
options.engineConfig ?? {
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
updated_at: 1_783_990_800,
},
);
return;
}
if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
seenAdminEndpoints.add("engine-config-patch");
const body = request.postDataJSON() as Partial<AdminEngineConfigResponse>;
await fulfillJson({
...(options.engineConfig ?? {
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
}),
...body,
source: "database",
durable: true,
window_days: 7,
total_turns: 0,
metered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
budget: {
limit_usd: 0,
used_ratio: 0,
remaining_usd: null,
status: "disabled",
},
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [],
daily_cost: [],
updated_by: "stale-admin@twentyoz.kr",
updated_at: 1_783_990_900,
});
return;
}
@ -568,7 +618,9 @@ async function expectVisibleButtonsFit(page: Page, selector: string, context: st
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".ad-user,.ad-user-create") ?? button.parentElement;
const owner =
button.closest<HTMLElement>(".ad-user,.ad-user-create,.ad-user-table tbody tr") ??
button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
@ -636,6 +688,99 @@ test.describe("admin route guards", () => {
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
});
test("shows detailed AI metering and saves the real engine configuration", async ({ page }) => {
const seenAdminEndpoints = await mockAdminSession(
page,
{
user_id: "ai-admin",
email: "ai-admin@twentyoz.kr",
display_name: "AI Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
},
{
adminUsage: {
source: "database",
durable: true,
generated_at: 1_783_990_800,
window_days: 30,
total_turns: 32,
metered_turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
budget: {
limit_usd: 20,
used_ratio: 0.33106,
remaining_usd: 13.3788,
status: "ok",
},
evaluator_cache: {
enabled: true,
entries: 12,
hits: 8,
misses: 2,
stores: 2,
evictions: 1,
requests: 10,
hit_rate: 0.8,
},
by_provider: [
{
provider: "openai",
model: "gpt-5-mini",
turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
},
],
daily_cost: [
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
{ day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
],
},
},
);
await page.goto("/admin/ai");
await expect(page).toHaveURL(/\/admin\/ai$/);
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toHaveAttribute(
"aria-current",
"page",
);
await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
await expect(page.locator(".aic-ledger")).toContainText("$6.6212");
await expect(page.locator(".aic-budget")).toContainText("93.8%");
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
const usageRequest = page.waitForRequest((request) =>
request.url().includes("/api/admin/usage?window_days=7"),
);
await page.getByRole("button", { name: "7일" }).click();
await usageRequest;
await page.getByLabel("AI 기본 모델").fill("gpt-5.1-mini");
const patchRequest = page.waitForRequest((request) =>
request.method() === "PATCH" && request.url().endsWith("/api/admin/engine-config"),
);
await page.getByRole("button", { name: "운영 설정 저장" }).click();
const request = await patchRequest;
expect(request.postDataJSON()).toMatchObject({ model: "gpt-5.1-mini" });
await expect(page.getByText("저장됨")).toBeVisible();
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["engine-config", "engine-config-patch", "health", "usage"]);
await expectNoHorizontalOverflow(page);
});
test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
await mockAdminSession(page, {
user_id: "scroll-admin",
@ -663,7 +808,40 @@ test.describe("admin route guards", () => {
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeInViewport();
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeVisible();
});
test("resets scroll when the browser restores an existing admin tab", async ({ page }) => {
await mockAdminSession(page, {
user_id: "restored-tab-admin",
email: "restored-tab-admin@twentyoz.kr",
display_name: "Restored Tab Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
});
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
const scrolled = await page.evaluate(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
});
expect(scrolled).toBeGreaterThan(0);
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
});
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => {
@ -849,6 +1027,7 @@ test.describe("admin route", () => {
await page.goto("/admin");
const nav = page.locator(".vg-nav");
await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect(nav.getByRole("link", { name: "AI 운영" })).toBeVisible();
await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible();
await expect(nav.getByRole("link", { name: "권한" })).toBeVisible();
await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible();
@ -886,6 +1065,18 @@ test.describe("admin route", () => {
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
const userTable = page.getByRole("table");
await expect(userTable).toBeVisible();
const sessionHeader = page.getByRole("columnheader", { name: /활성 회기/ });
await sessionHeader.getByRole("button").click();
await expect(sessionHeader).toHaveAttribute("aria-sort", "ascending");
const ascendingSessions = await userTable.locator("tbody tr td:nth-child(7)").allTextContents();
expect(ascendingSessions.map(Number)).toEqual(
ascendingSessions.map(Number).slice().sort((a, b) => a - b),
);
await sessionHeader.getByRole("button").click();
await expect(sessionHeader).toHaveAttribute("aria-sort", "descending");
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
@ -934,9 +1125,9 @@ test.describe("admin route", () => {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
const card = page.locator(".ad-user-table tbody tr").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expect(card.getByLabel(`${email} 표시 이름`)).toHaveValue(displayName);
await expect(card).toContainText("승인됨");
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
@ -970,7 +1161,7 @@ test.describe("admin route", () => {
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(nameInput).toHaveValue(nextName);
await expect(card).toContainText("교수자");
await expect(cohortInput).toHaveValue(nextCohort);
@ -1136,6 +1327,20 @@ test.describe("admin route", () => {
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await page.getByRole("tab", { name: "사용자 목록" }).click();
const scrollRegion = page.locator(".ad-user-table-scroll");
const scrollContract = await scrollRegion.evaluate((element) => {
const before = element.scrollLeft;
element.scrollLeft = element.scrollWidth;
return {
before,
after: element.scrollLeft,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
};
});
expect(scrollContract.scrollWidth).toBeGreaterThan(scrollContract.clientWidth);
expect(scrollContract.after).toBeGreaterThan(scrollContract.before);
await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});