전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue