From 8205306f2fcbedbd01e6eb9e700c5a847a25a848 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 2 Aug 2026 01:17:30 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20API=20=ED=99=98=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=ED=8C=90=EB=B3=84=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20stage=20=EB=84=A4=EC=9D=B4=EB=B0=8D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인 이메일로 dev/prod를 자동 판별하던 방식을 제거하고, 저장된 환경 값을 "stage"/"prod"로 통일했다. 레거시로 저장된 "dev" 값은 loadAdminApiEnvironment에서 "stage"로 해석해 기존 사용자 선택을 보존한다. 환경 전환은 이후 FAB이 호출할 switchAdminApiEnvironment(clearSession → saveAdminApiEnvironment → 리다이렉트)로 일원화한다. --- .../components/layout/EnvironmentBanner.tsx | 6 +- apps/admin/src/lib/api/auth.test.ts | 59 +++++++----------- apps/admin/src/lib/api/auth.ts | 13 +--- apps/admin/src/lib/auth/environment.test.ts | 62 +++++++++++++------ apps/admin/src/lib/auth/environment.ts | 11 +--- apps/admin/src/lib/auth/session.ts | 19 ++++++ apps/admin/src/lib/env.ts | 5 +- apps/admin/src/lib/utils/localStorage.test.ts | 42 ++++++++++++- apps/admin/src/lib/utils/localStorage.ts | 6 +- 9 files changed, 141 insertions(+), 82 deletions(-) diff --git a/apps/admin/src/components/layout/EnvironmentBanner.tsx b/apps/admin/src/components/layout/EnvironmentBanner.tsx index b4af8735..01d894cb 100644 --- a/apps/admin/src/components/layout/EnvironmentBanner.tsx +++ b/apps/admin/src/components/layout/EnvironmentBanner.tsx @@ -4,16 +4,16 @@ import { useEffect, useState } from "react"; import { getAdminApiServerUrl } from "@/lib/env"; import { loadAdminApiEnvironment } from "@/lib/utils/localStorage"; -type DisplayedEnvironment = "dev" | "prod" | "local"; +type DisplayedEnvironment = "stage" | "prod" | "local"; const environmentStyles: Record = { - dev: "bg-magic-success-surface text-magic-success", + stage: "bg-magic-success-surface text-magic-success", prod: "bg-magic-danger-surface text-magic-danger", local: "bg-bg-50 text-k-600", }; const environmentLabels: Record = { - dev: "DEV", + stage: "STAGE", prod: "PROD", local: "LOCAL", }; diff --git a/apps/admin/src/lib/api/auth.test.ts b/apps/admin/src/lib/api/auth.test.ts index 6a49e4e6..62d82bbd 100644 --- a/apps/admin/src/lib/api/auth.test.ts +++ b/apps/admin/src/lib/api/auth.test.ts @@ -1,27 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { create, loadAccessToken, post, removeAccessToken, requestInterceptors, saveAdminApiEnvironment } = vi.hoisted( - () => { - const hoistedPost = vi.fn(); - return { - create: vi.fn(() => ({ - post: hoistedPost, - interceptors: { - request: { - use: (fn: (config: Record) => Record) => { - requestInterceptors.push(fn); - }, +const { create, loadAccessToken, post, removeAccessToken, requestInterceptors } = vi.hoisted(() => { + const hoistedPost = vi.fn(); + return { + create: vi.fn(() => ({ + post: hoistedPost, + interceptors: { + request: { + use: (fn: (config: Record) => Record) => { + requestInterceptors.push(fn); }, }, - })), - loadAccessToken: vi.fn(), - post: hoistedPost, - removeAccessToken: vi.fn(), - requestInterceptors: [] as Array<(config: Record) => Record>, - saveAdminApiEnvironment: vi.fn(), - }; - }, -); + }, + })), + loadAccessToken: vi.fn(), + post: hoistedPost, + removeAccessToken: vi.fn(), + requestInterceptors: [] as Array<(config: Record) => Record>, + }; +}); vi.mock("axios", () => ({ default: { @@ -29,12 +26,6 @@ vi.mock("axios", () => ({ }, })); -vi.mock("@/lib/auth/environment", () => ({ - getApiBaseUrlForEnvironment: (environment: string) => - environment === "dev" ? "https://api.stage.solid-connection.com" : "https://api.solid-connection.com", - resolveEnvironmentFromEmail: (email: string) => (email === "dev@solid-connection.com" ? "dev" : "prod"), -})); - vi.mock("@/lib/env", () => ({ resolveActiveApiBaseUrl: () => "https://api.solid-connection.com", })); @@ -42,7 +33,6 @@ vi.mock("@/lib/env", () => ({ vi.mock("@/lib/utils/localStorage", () => ({ loadAccessToken, removeAccessToken, - saveAdminApiEnvironment, })); import { adminSignInApi, adminSignOutApi, reissueAccessTokenApi } from "./auth"; @@ -52,7 +42,6 @@ describe("어드민 인증 API", () => { loadAccessToken.mockReset(); post.mockReset(); removeAccessToken.mockReset(); - saveAdminApiEnvironment.mockReset(); }); it("어드민 refresh token 쿠키를 주고받도록 credentials를 포함한다", () => { @@ -61,18 +50,16 @@ describe("어드민 인증 API", () => { }); }); - it("어드민 전용 로그인 API를 이메일 환경에 맞춰 호출한다", async () => { + it("어드민 전용 로그인 API를 호출하고 이전 access token을 정리한다", async () => { post.mockResolvedValue({ data: { accessToken: "access-token" } }); - await adminSignInApi("dev@solid-connection.com", "password"); + await adminSignInApi("admin@solid-connection.com", "password"); expect(removeAccessToken).toHaveBeenCalledOnce(); - expect(saveAdminApiEnvironment).toHaveBeenCalledWith("dev"); - expect(post).toHaveBeenCalledWith( - "/admin/auth/sign-in", - { email: "dev@solid-connection.com", password: "password" }, - { baseURL: "https://api.stage.solid-connection.com" }, - ); + expect(post).toHaveBeenCalledWith("/admin/auth/sign-in", { + email: "admin@solid-connection.com", + password: "password", + }); }); it("어드민 전용 재발급 API를 호출한다", async () => { diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts index ae246cc5..d24cf009 100644 --- a/apps/admin/src/lib/api/auth.ts +++ b/apps/admin/src/lib/api/auth.ts @@ -1,7 +1,6 @@ import axios, { type AxiosResponse } from "axios"; -import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "@/lib/auth/environment"; import { resolveActiveApiBaseUrl } from "@/lib/env"; -import { loadAccessToken, removeAccessToken, saveAdminApiEnvironment } from "@/lib/utils/localStorage"; +import { loadAccessToken, removeAccessToken } from "@/lib/utils/localStorage"; import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth"; const authAxiosInstance = axios.create({ @@ -12,22 +11,14 @@ const ADMIN_AUTH_PATH = "/admin/auth"; authAxiosInstance.interceptors.request.use((config) => { const newConfig = { ...config }; - // 로그인에서 직접 지정한 baseURL을 우선하고, 나머지 요청은 저장된 어드민 환경을 사용한다. newConfig.baseURL = config.baseURL ?? resolveActiveApiBaseUrl(); return newConfig; }); export const adminSignInApi = (email: string, password: string): Promise> => { - const environment = resolveEnvironmentFromEmail(email); - removeAccessToken(); - saveAdminApiEnvironment(environment); - return authAxiosInstance.post( - `${ADMIN_AUTH_PATH}/sign-in`, - { email, password }, - { baseURL: getApiBaseUrlForEnvironment(environment) }, - ); + return authAxiosInstance.post(`${ADMIN_AUTH_PATH}/sign-in`, { email, password }); }; export const reissueAccessTokenApi = (): Promise> => { diff --git a/apps/admin/src/lib/auth/environment.test.ts b/apps/admin/src/lib/auth/environment.test.ts index e081fb4d..5c684cdb 100644 --- a/apps/admin/src/lib/auth/environment.test.ts +++ b/apps/admin/src/lib/auth/environment.test.ts @@ -1,28 +1,52 @@ -import { describe, expect, it } from "vitest"; -import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "./environment"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getApiBaseUrlForEnvironment } from "./environment"; -describe("resolveEnvironmentFromEmail", () => { - it("dev 계정 이메일(dev@solid-connection.com)은 dev 환경으로 판별한다", () => { - expect(resolveEnvironmentFromEmail("dev@solid-connection.com")).toBe("dev"); +describe("getApiBaseUrlForEnvironment", () => { + it("환경에 맞는 API base URL을 반환한다", () => { + expect(getApiBaseUrlForEnvironment("stage")).toBe("https://api.stage.solid-connection.com"); + expect(getApiBaseUrlForEnvironment("prod")).toBe("https://api.solid-connection.com"); }); +}); - it("대소문자와 앞뒤 공백을 무시하고 판별한다", () => { - expect(resolveEnvironmentFromEmail(" Dev@Solid-Connection.Com ")).toBe("dev"); - }); +const { removeAccessToken, removeAdminApiEnvironment, saveAdminApiEnvironment } = vi.hoisted(() => ({ + removeAccessToken: vi.fn(), + removeAdminApiEnvironment: vi.fn(), + saveAdminApiEnvironment: vi.fn(), +})); - it("dev 계정 이메일이 아닌 이메일은 prod 환경으로 판별한다", () => { - expect(resolveEnvironmentFromEmail("admin@solid-connection.com")).toBe("prod"); - }); +vi.mock("@/lib/api/auth", () => ({ + reissueAccessTokenApi: vi.fn(), +})); + +vi.mock("@/lib/utils/localStorage", () => ({ + loadAccessToken: vi.fn(), + removeAccessToken, + removeAdminApiEnvironment, + saveAccessToken: vi.fn(), + saveAdminApiEnvironment, +})); - it("로컬파트가 dev로 시작하지만 정확히 일치하지 않는 이메일은 prod로 판별한다", () => { - expect(resolveEnvironmentFromEmail("dev2@solid-connection.com")).toBe("prod"); - expect(resolveEnvironmentFromEmail("dev@notsolid-connection.com")).toBe("prod"); +describe("switchAdminApiEnvironment", () => { + beforeEach(() => { + removeAccessToken.mockReset(); + removeAdminApiEnvironment.mockReset(); + saveAdminApiEnvironment.mockReset(); }); -}); -describe("getApiBaseUrlForEnvironment", () => { - it("환경에 맞는 API base URL을 반환한다", () => { - expect(getApiBaseUrlForEnvironment("dev")).toBe("https://api.stage.solid-connection.com"); - expect(getApiBaseUrlForEnvironment("prod")).toBe("https://api.solid-connection.com"); + it("세션(access token, 환경 값)을 모두 지운 뒤 새 환경을 저장한다", async () => { + const { switchAdminApiEnvironment } = await import("./session"); + const redirect = vi.fn(); + const callOrder: string[] = []; + + removeAdminApiEnvironment.mockImplementation(() => callOrder.push("removeAdminApiEnvironment")); + saveAdminApiEnvironment.mockImplementation(() => callOrder.push("saveAdminApiEnvironment")); + + switchAdminApiEnvironment("prod", redirect); + + expect(removeAccessToken).toHaveBeenCalledOnce(); + expect(removeAdminApiEnvironment).toHaveBeenCalledOnce(); + expect(saveAdminApiEnvironment).toHaveBeenCalledWith("prod"); + expect(callOrder).toEqual(["removeAdminApiEnvironment", "saveAdminApiEnvironment"]); + expect(redirect).toHaveBeenCalledWith("/auth/login"); }); }); diff --git a/apps/admin/src/lib/auth/environment.ts b/apps/admin/src/lib/auth/environment.ts index 4ac54ff9..ae2d2d56 100644 --- a/apps/admin/src/lib/auth/environment.ts +++ b/apps/admin/src/lib/auth/environment.ts @@ -1,15 +1,8 @@ -export type AdminApiEnvironment = "dev" | "prod"; - -const DEV_ACCOUNT_EMAIL = "dev@solid-connection.com"; +export type AdminApiEnvironment = "stage" | "prod"; const API_BASE_URLS: Record = { - dev: "https://api.stage.solid-connection.com", + stage: "https://api.stage.solid-connection.com", prod: "https://api.solid-connection.com", }; -export const resolveEnvironmentFromEmail = (email: string): AdminApiEnvironment => { - const normalizedEmail = email.trim().toLowerCase(); - return normalizedEmail === DEV_ACCOUNT_EMAIL ? "dev" : "prod"; -}; - export const getApiBaseUrlForEnvironment = (environment: AdminApiEnvironment): string => API_BASE_URLS[environment]; diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts index 93104ee8..daa7ebfd 100644 --- a/apps/admin/src/lib/auth/session.ts +++ b/apps/admin/src/lib/auth/session.ts @@ -1,10 +1,12 @@ import { reissueAccessTokenApi } from "@/lib/api/auth"; +import type { AdminApiEnvironment } from "@/lib/auth/environment"; import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken, removeAccessToken, removeAdminApiEnvironment, saveAccessToken, + saveAdminApiEnvironment, } from "@/lib/utils/localStorage"; let reissuePromise: Promise | null = null; @@ -31,6 +33,23 @@ export const clearSession = () => { removeAdminApiEnvironment(); }; +/** + * 어드민 API 환경(stage/prod)을 전환한다. clearSession()이 환경 저장 키까지 함께 지우므로, + * 반드시 clearSession → saveAdminApiEnvironment 순서로 처리해야 새 환경 값이 유지된다. + * 이후 react-query 캐시 등 메모리 상태를 확실히 비우기 위해 전체 페이지 이동으로 리다이렉트한다. + * redirect는 테스트에서 주입할 수 있도록 매개변수로 분리했다. + */ +export const switchAdminApiEnvironment = ( + next: AdminApiEnvironment, + redirect: (url: string) => void = (url) => { + window.location.href = url; + }, +) => { + clearSession(); + saveAdminApiEnvironment(next); + redirect("/auth/login"); +}; + export const reissueAccessTokenIfPossible = async (): Promise => { if (reissuePromise) { return reissuePromise; diff --git a/apps/admin/src/lib/env.ts b/apps/admin/src/lib/env.ts index fc83b62e..d76f332c 100644 --- a/apps/admin/src/lib/env.ts +++ b/apps/admin/src/lib/env.ts @@ -4,12 +4,13 @@ import { loadAdminApiEnvironment } from "@/lib/utils/localStorage"; const getTrimmedEnv = (key: string) => import.meta.env[key]?.trim() ?? ""; // Local-dev-only override (e.g. http://localhost:8080). When unset, the API server -// is resolved at runtime from the signed-in account's environment (see resolveActiveApiBaseUrl). +// is resolved at runtime from the environment stored via the environment switcher FAB +// (see resolveActiveApiBaseUrl). export const getAdminApiServerUrl = () => getTrimmedEnv("VITE_API_SERVER_URL"); export const resolveActiveApiBaseUrl = (): string => { // The override only applies to `vinext dev` (import.meta.env.DEV). Deployed builds - // (Preview/Production) always resolve dev/prod from the signed-in account's email, + // (Preview/Production) always resolve stage/prod from the stored environment selection, // regardless of whether VITE_API_SERVER_URL happens to still be set on Vercel. const localOverride = import.meta.env.DEV ? getAdminApiServerUrl() : ""; if (localOverride) { diff --git a/apps/admin/src/lib/utils/localStorage.test.ts b/apps/admin/src/lib/utils/localStorage.test.ts index 972ff643..e88eb955 100644 --- a/apps/admin/src/lib/utils/localStorage.test.ts +++ b/apps/admin/src/lib/utils/localStorage.test.ts @@ -1,8 +1,16 @@ import { afterEach, describe, expect, it } from "vitest"; -import { loadAccessToken, removeAccessToken, saveAccessToken } from "./localStorage"; +import { + loadAccessToken, + loadAdminApiEnvironment, + removeAccessToken, + removeAdminApiEnvironment, + saveAccessToken, + saveAdminApiEnvironment, +} from "./localStorage"; const ADMIN_ACCESS_TOKEN_KEY = "adminAccessToken"; +const ADMIN_API_ENVIRONMENT_KEY = "adminApiEnvironment"; describe("어드민 access token localStorage 저장", () => { afterEach(() => { @@ -35,3 +43,35 @@ describe("어드민 access token localStorage 저장", () => { expect(localStorage.getItem("accessToken")).toBeNull(); }); }); + +describe("어드민 API 환경 localStorage 저장", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("환경 값을 저장하고 조회한다", () => { + saveAdminApiEnvironment("prod"); + + expect(loadAdminApiEnvironment()).toBe("prod"); + }); + + it("환경 값을 제거한다", () => { + saveAdminApiEnvironment("stage"); + + removeAdminApiEnvironment(); + + expect(loadAdminApiEnvironment()).toBeNull(); + }); + + it("레거시 값 'dev'는 'stage'로 해석한다", () => { + localStorage.setItem(ADMIN_API_ENVIRONMENT_KEY, "dev"); + + expect(loadAdminApiEnvironment()).toBe("stage"); + }); + + it("알 수 없는 값은 null을 반환한다", () => { + localStorage.setItem(ADMIN_API_ENVIRONMENT_KEY, "unknown"); + + expect(loadAdminApiEnvironment()).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/utils/localStorage.ts b/apps/admin/src/lib/utils/localStorage.ts index 4cc69a35..e68a6058 100644 --- a/apps/admin/src/lib/utils/localStorage.ts +++ b/apps/admin/src/lib/utils/localStorage.ts @@ -35,7 +35,11 @@ export const loadAdminApiEnvironment = (): AdminApiEnvironment | null => { try { const value = localStorage.getItem(ADMIN_API_ENVIRONMENT_KEY); - return value === "dev" || value === "prod" ? value : null; + // 레거시 값("dev")으로 저장된 기존 사용자의 선택을 보존하기 위해 stage로 해석한다. + if (value === "dev") { + return "stage"; + } + return value === "stage" || value === "prod" ? value : null; } catch (err) { console.error("Could not load admin api environment", err); return null; From b2b68340c1a606e3a7c62109be81a79a1f6abd00 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 2 Aug 2026 01:17:51 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20=EC=9A=B0=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=ED=8C=85=20=EB=B2=84=ED=8A=BC=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=96=B4=EB=93=9C=EB=AF=BC=20API=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=A0=84=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 어드민 배포가 main 하나로 통합됨에 따라, 로그인 이메일 대신 화면 우하단 플로팅 버튼(FAB)으로 stage/prod API 환경을 명시적으로 전환하도록 했다. 클릭 시 confirm으로 로그아웃 경고 후 switchAdminApiEnvironment를 호출해 세션을 정리하고 환경을 저장한 뒤 /auth/login으로 전체 페이지 이동한다. 로컬 오버라이드(VITE_API_SERVER_URL) 상태에서는 LOCAL로 표시하고 전환을 비활성화한다. 로그인 전에도 환경을 고를 수 있도록 루트 레이아웃에 마운트했다. --- apps/admin/src/app/layout.tsx | 6 +- .../layout/EnvironmentSwitcherFab.tsx | 74 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 95d655da..dc8039a1 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import "../styles.css"; +import { EnvironmentSwitcherFab } from "@/components/layout/EnvironmentSwitcherFab"; import { Providers } from "./providers"; export const metadata = { @@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + + {children} + + ); diff --git a/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx b/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx new file mode 100644 index 00000000..b90b106c --- /dev/null +++ b/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AdminApiEnvironment } from "@/lib/auth/environment"; +import { switchAdminApiEnvironment } from "@/lib/auth/session"; +import { getAdminApiServerUrl } from "@/lib/env"; +import { loadAdminApiEnvironment } from "@/lib/utils/localStorage"; + +type DisplayedEnvironment = AdminApiEnvironment | "local"; + +const environmentStyles: Record = { + stage: "bg-magic-success-surface text-magic-success", + prod: "bg-magic-danger-surface text-magic-danger", + local: "bg-bg-50 text-k-600", +}; + +const environmentLabels: Record = { + stage: "STAGE", + prod: "PROD", + local: "LOCAL", +}; + +const nextEnvironmentOf = (environment: AdminApiEnvironment): AdminApiEnvironment => + environment === "stage" ? "prod" : "stage"; + +const resolveDisplayedEnvironment = (): DisplayedEnvironment => { + if (import.meta.env.DEV && getAdminApiServerUrl()) { + return "local"; + } + return loadAdminApiEnvironment() ?? "prod"; +}; + +export function EnvironmentSwitcherFab() { + const [environment, setEnvironment] = useState(null); + + useEffect(() => { + setEnvironment(resolveDisplayedEnvironment()); + + const handleStorageChange = () => setEnvironment(resolveDisplayedEnvironment()); + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, []); + + if (!environment) { + return null; + } + + const isLocal = environment === "local"; + + const handleClick = () => { + if (isLocal) { + return; + } + + const next = nextEnvironmentOf(environment); + const confirmed = window.confirm(`환경을 ${environmentLabels[next]}(으)로 전환하면 로그아웃됩니다. 계속할까요?`); + if (!confirmed) { + return; + } + + switchAdminApiEnvironment(next); + }; + + return ( + + ); +} From 10e7dcc0e26e59e99f035258b8aafc897bb21edc Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 2 Aug 2026 01:25:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=ED=91=9C=EC=8B=9C=20=EC=83=81=EC=88=98=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=ED=99=94=20=EB=B0=8F=20=EC=84=B8=EC=85=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/layout/EnvironmentBanner.tsx | 29 +++--------- .../layout/EnvironmentSwitcherFab.tsx | 29 +++--------- .../components/layout/environmentDisplay.ts | 24 ++++++++++ apps/admin/src/lib/auth/environment.test.ts | 45 +------------------ apps/admin/src/lib/auth/session.test.ts | 44 ++++++++++++++++++ 5 files changed, 81 insertions(+), 90 deletions(-) create mode 100644 apps/admin/src/components/layout/environmentDisplay.ts create mode 100644 apps/admin/src/lib/auth/session.test.ts diff --git a/apps/admin/src/components/layout/EnvironmentBanner.tsx b/apps/admin/src/components/layout/EnvironmentBanner.tsx index 01d894cb..1b96dabf 100644 --- a/apps/admin/src/components/layout/EnvironmentBanner.tsx +++ b/apps/admin/src/components/layout/EnvironmentBanner.tsx @@ -1,29 +1,12 @@ "use client"; import { useEffect, useState } from "react"; -import { getAdminApiServerUrl } from "@/lib/env"; -import { loadAdminApiEnvironment } from "@/lib/utils/localStorage"; - -type DisplayedEnvironment = "stage" | "prod" | "local"; - -const environmentStyles: Record = { - stage: "bg-magic-success-surface text-magic-success", - prod: "bg-magic-danger-surface text-magic-danger", - local: "bg-bg-50 text-k-600", -}; - -const environmentLabels: Record = { - stage: "STAGE", - prod: "PROD", - local: "LOCAL", -}; - -const resolveDisplayedEnvironment = (): DisplayedEnvironment => { - if (import.meta.env.DEV && getAdminApiServerUrl()) { - return "local"; - } - return loadAdminApiEnvironment() ?? "prod"; -}; +import { + type DisplayedEnvironment, + environmentLabels, + environmentStyles, + resolveDisplayedEnvironment, +} from "./environmentDisplay"; export function EnvironmentBanner() { const [environment, setEnvironment] = useState(null); diff --git a/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx b/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx index b90b106c..e7362be9 100644 --- a/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx +++ b/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx @@ -3,33 +3,16 @@ import { useEffect, useState } from "react"; import type { AdminApiEnvironment } from "@/lib/auth/environment"; import { switchAdminApiEnvironment } from "@/lib/auth/session"; -import { getAdminApiServerUrl } from "@/lib/env"; -import { loadAdminApiEnvironment } from "@/lib/utils/localStorage"; - -type DisplayedEnvironment = AdminApiEnvironment | "local"; - -const environmentStyles: Record = { - stage: "bg-magic-success-surface text-magic-success", - prod: "bg-magic-danger-surface text-magic-danger", - local: "bg-bg-50 text-k-600", -}; - -const environmentLabels: Record = { - stage: "STAGE", - prod: "PROD", - local: "LOCAL", -}; +import { + type DisplayedEnvironment, + environmentLabels, + environmentStyles, + resolveDisplayedEnvironment, +} from "./environmentDisplay"; const nextEnvironmentOf = (environment: AdminApiEnvironment): AdminApiEnvironment => environment === "stage" ? "prod" : "stage"; -const resolveDisplayedEnvironment = (): DisplayedEnvironment => { - if (import.meta.env.DEV && getAdminApiServerUrl()) { - return "local"; - } - return loadAdminApiEnvironment() ?? "prod"; -}; - export function EnvironmentSwitcherFab() { const [environment, setEnvironment] = useState(null); diff --git a/apps/admin/src/components/layout/environmentDisplay.ts b/apps/admin/src/components/layout/environmentDisplay.ts new file mode 100644 index 00000000..6577abf2 --- /dev/null +++ b/apps/admin/src/components/layout/environmentDisplay.ts @@ -0,0 +1,24 @@ +import type { AdminApiEnvironment } from "@/lib/auth/environment"; +import { getAdminApiServerUrl } from "@/lib/env"; +import { loadAdminApiEnvironment } from "@/lib/utils/localStorage"; + +export type DisplayedEnvironment = AdminApiEnvironment | "local"; + +export const environmentStyles: Record = { + stage: "bg-magic-success-surface text-magic-success", + prod: "bg-magic-danger-surface text-magic-danger", + local: "bg-bg-50 text-k-600", +}; + +export const environmentLabels: Record = { + stage: "STAGE", + prod: "PROD", + local: "LOCAL", +}; + +export const resolveDisplayedEnvironment = (): DisplayedEnvironment => { + if (import.meta.env.DEV && getAdminApiServerUrl()) { + return "local"; + } + return loadAdminApiEnvironment() ?? "prod"; +}; diff --git a/apps/admin/src/lib/auth/environment.test.ts b/apps/admin/src/lib/auth/environment.test.ts index 5c684cdb..524c94eb 100644 --- a/apps/admin/src/lib/auth/environment.test.ts +++ b/apps/admin/src/lib/auth/environment.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { getApiBaseUrlForEnvironment } from "./environment"; describe("getApiBaseUrlForEnvironment", () => { @@ -7,46 +7,3 @@ describe("getApiBaseUrlForEnvironment", () => { expect(getApiBaseUrlForEnvironment("prod")).toBe("https://api.solid-connection.com"); }); }); - -const { removeAccessToken, removeAdminApiEnvironment, saveAdminApiEnvironment } = vi.hoisted(() => ({ - removeAccessToken: vi.fn(), - removeAdminApiEnvironment: vi.fn(), - saveAdminApiEnvironment: vi.fn(), -})); - -vi.mock("@/lib/api/auth", () => ({ - reissueAccessTokenApi: vi.fn(), -})); - -vi.mock("@/lib/utils/localStorage", () => ({ - loadAccessToken: vi.fn(), - removeAccessToken, - removeAdminApiEnvironment, - saveAccessToken: vi.fn(), - saveAdminApiEnvironment, -})); - -describe("switchAdminApiEnvironment", () => { - beforeEach(() => { - removeAccessToken.mockReset(); - removeAdminApiEnvironment.mockReset(); - saveAdminApiEnvironment.mockReset(); - }); - - it("세션(access token, 환경 값)을 모두 지운 뒤 새 환경을 저장한다", async () => { - const { switchAdminApiEnvironment } = await import("./session"); - const redirect = vi.fn(); - const callOrder: string[] = []; - - removeAdminApiEnvironment.mockImplementation(() => callOrder.push("removeAdminApiEnvironment")); - saveAdminApiEnvironment.mockImplementation(() => callOrder.push("saveAdminApiEnvironment")); - - switchAdminApiEnvironment("prod", redirect); - - expect(removeAccessToken).toHaveBeenCalledOnce(); - expect(removeAdminApiEnvironment).toHaveBeenCalledOnce(); - expect(saveAdminApiEnvironment).toHaveBeenCalledWith("prod"); - expect(callOrder).toEqual(["removeAdminApiEnvironment", "saveAdminApiEnvironment"]); - expect(redirect).toHaveBeenCalledWith("/auth/login"); - }); -}); diff --git a/apps/admin/src/lib/auth/session.test.ts b/apps/admin/src/lib/auth/session.test.ts new file mode 100644 index 00000000..2c623e5d --- /dev/null +++ b/apps/admin/src/lib/auth/session.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { removeAccessToken, removeAdminApiEnvironment, saveAdminApiEnvironment } = vi.hoisted(() => ({ + removeAccessToken: vi.fn(), + removeAdminApiEnvironment: vi.fn(), + saveAdminApiEnvironment: vi.fn(), +})); + +vi.mock("@/lib/api/auth", () => ({ + reissueAccessTokenApi: vi.fn(), +})); + +vi.mock("@/lib/utils/localStorage", () => ({ + loadAccessToken: vi.fn(), + removeAccessToken, + removeAdminApiEnvironment, + saveAccessToken: vi.fn(), + saveAdminApiEnvironment, +})); + +describe("switchAdminApiEnvironment", () => { + beforeEach(() => { + removeAccessToken.mockReset(); + removeAdminApiEnvironment.mockReset(); + saveAdminApiEnvironment.mockReset(); + }); + + it("세션(access token, 환경 값)을 모두 지운 뒤 새 환경을 저장한다", async () => { + const { switchAdminApiEnvironment } = await import("./session"); + const redirect = vi.fn(); + const callOrder: string[] = []; + + removeAdminApiEnvironment.mockImplementation(() => callOrder.push("removeAdminApiEnvironment")); + saveAdminApiEnvironment.mockImplementation(() => callOrder.push("saveAdminApiEnvironment")); + + switchAdminApiEnvironment("prod", redirect); + + expect(removeAccessToken).toHaveBeenCalledOnce(); + expect(removeAdminApiEnvironment).toHaveBeenCalledOnce(); + expect(saveAdminApiEnvironment).toHaveBeenCalledWith("prod"); + expect(callOrder).toEqual(["removeAdminApiEnvironment", "saveAdminApiEnvironment"]); + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); +});