feat(ai-character): Mock Preview 모드 구현

This commit is contained in:
Yu Sung
2026-07-27 23:23:36 +09:00
parent b4841ff579
commit 2a5efb0f3e
38 changed files with 4763 additions and 289 deletions

View File

@@ -0,0 +1,247 @@
import { z } from "zod";
import { describe, expect, test, vi } from "vitest";
import { login, logout } from "@/features/auth/api/auth-api";
import { createApiClient } from "@/shared/api/client";
import { createApiResponseSchema } from "@/shared/api/types";
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
const adminToken = "mock-admin-jwt";
const aiCharactersPreviewSchema = z.object({
totalCount: z.number(),
page: z.literal(0),
size: z.literal(20),
hasNext: z.boolean(),
items: z.array(z.unknown()),
});
function createClient(token: string | null = adminToken) {
return createApiClient({
getToken: () => token,
clearSession: vi.fn(),
onAuthExpired: vi.fn(),
});
}
function useMockHandlers(store = createMockStore()) {
server.use(...createMockHandlers(store, apiBaseUrl));
return store;
}
describe("mock auth handlers", () => {
test("use the production admin login endpoint, request body, headers, and response envelope", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useMockHandlers();
// When
const session = await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
const invalidBodyResponse = await fetch(`${apiBaseUrl}/admin/member/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "admin@test.com" }),
});
const authorizationResponse = await fetch(`${apiBaseUrl}/admin/member/login`, {
method: "POST",
headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
});
// Then
expect(session).toEqual({ token: adminToken, role: "ADMIN" });
await expect(invalidBodyResponse.json()).resolves.toEqual({
success: false,
message: "잘못된 요청입니다.",
data: null,
errorProperty: null,
});
expect(invalidBodyResponse.status).toBe(400);
expect(authorizationResponse.status).toBe(400);
});
test("does not handle login requests from a different API origin", async () => {
// Given
useMockHandlers();
// When
const request = fetch("https://wrong-origin.example/admin/member/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
});
// Then
await expect(request).rejects.toThrow();
});
test("requires Bearer and no body for production logout", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useMockHandlers();
// When
const missingBearerResponse = await fetch(`${apiBaseUrl}/member/logout`, { method: "POST" });
const bodyResponse = await fetch(`${apiBaseUrl}/member/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${adminToken}` },
body: "{}",
});
// Then
expect(missingBearerResponse.status).toBe(401);
expect(bodyResponse.status).toBe(400);
});
test("returns 403 when logout uses a non-ADMIN Bearer token", async () => {
// Given
useMockHandlers();
// When
const response = await fetch(`${apiBaseUrl}/member/logout`, {
method: "POST",
headers: { Authorization: "Bearer mock-member-jwt" },
});
// Then
expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({
success: false,
message: "접근 권한이 없습니다.",
data: null,
errorProperty: null,
});
});
test("returns 415 when login uses a non-JSON media type", async () => {
// Given
useMockHandlers();
// When
const response = await fetch(`${apiBaseUrl}/admin/member/login`, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
});
// Then
expect(response.status).toBe(415);
await expect(response.json()).resolves.toEqual({
success: false,
message: "지원하지 않는 미디어 타입입니다.",
data: null,
errorProperty: null,
});
});
test("returns 401 when logout uses an invalid or already revoked token", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useMockHandlers();
// When
const invalidTokenResponse = await fetch(`${apiBaseUrl}/member/logout`, {
method: "POST",
headers: { Authorization: "Bearer invalid-token" },
});
await logout(createClient(adminToken));
const revokedTokenResponse = await fetch(`${apiBaseUrl}/member/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${adminToken}` },
});
// Then
expect(invalidTokenResponse.status).toBe(401);
expect(revokedTokenResponse.status).toBe(401);
});
test("resets the in-memory auth store to seed when a fresh mock store is created", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useMockHandlers();
await logout(createClient(adminToken));
// When
const staleStoreResponse = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
useMockHandlers(createMockStore());
const freshStoreResponse = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const parsedFreshResponse = createApiResponseSchema(aiCharactersPreviewSchema).parse(await freshStoreResponse.json());
// Then
expect(staleStoreResponse.status).toBe(401);
expect(freshStoreResponse.status).toBe(200);
expect(parsedFreshResponse).toEqual({
success: true,
message: null,
data: { totalCount: 0, page: 0, size: 20, hasNext: false, items: [] },
errorProperty: null,
});
});
test("reactivates the admin preview token after logout and login in the same store", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useMockHandlers();
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
await logout(createClient(adminToken));
// When
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
const response = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
// Then
expect(response.status).toBe(200);
});
test("returns the contract 403 fixture for a non-ADMIN Bearer token", async () => {
// Given
useMockHandlers();
// When
const response = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
headers: { Authorization: "Bearer mock-member-jwt" },
});
// Then
expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({
success: false,
message: "접근 권한이 없습니다.",
data: null,
errorProperty: null,
});
});
test("keeps mock fixture state out of browser persistent storage and logs", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const indexedDbOpen = vi.fn();
const consoleLog = vi.spyOn(console, "log");
const consoleWarn = vi.spyOn(console, "warn");
const consoleError = vi.spyOn(console, "error");
vi.stubGlobal("indexedDB", { open: indexedDbOpen });
const cookieBefore = document.cookie;
useMockHandlers();
// When
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
await logout(createClient(adminToken));
// Then
expect(localStorage).toHaveLength(0);
expect(sessionStorage).toHaveLength(0);
expect(indexedDbOpen).not.toHaveBeenCalled();
expect(document.cookie).toBe(cookieBefore);
expect(consoleLog).not.toHaveBeenCalled();
expect(consoleWarn).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,66 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import packageJson from "../../../../package.json";
const rootDir = process.cwd();
function projectFile(path: string): string {
const absolutePath = join(rootDir, path);
expect(existsSync(absolutePath)).toBe(true);
return readFileSync(absolutePath, "utf8");
}
function expectContainsEvery(source: string, tokens: readonly string[]): void {
for (const token of tokens) {
expect(source).toContain(token);
}
}
describe("mock preview documentation", () => {
test("documents the actual npm scripts and mode boundary in README", () => {
// Given
const readme = projectFile("README.md");
// When
const actualScripts = [
`npm run dev (${packageJson.scripts.dev})`,
`npm run dev:mock (${packageJson.scripts["dev:mock"]})`,
`npm run e2e (${packageJson.scripts.e2e})`,
`npm run e2e:mock (${packageJson.scripts["e2e:mock"]})`,
];
// Then
expectContainsEvery(readme, actualScripts);
expectContainsEvery(readme, ["server mode", "mock mode", "VITE_API_MODE=server", "VITE_API_MODE=mock"]);
expectContainsEvery(readme, ["mock data reset", "production", "no-auto-fallback"]);
});
test("keeps agent environment and script guides synced with mock preview ownership rules", () => {
// Given
const environment = projectFile("docs/agent-guide/environment.md");
const scripts = projectFile("docs/agent-guide/scripts.md");
// When, Then
expectContainsEvery(environment, ["VITE_API_MODE=server | mock", "npm run dev", "npm run dev:mock"]);
expectContainsEvery(environment, ["mock data reset", "production", "no-auto-fallback"]);
expectContainsEvery(scripts, ["npm run dev", "npm run dev:mock", "npm run e2e", "npm run e2e:mock"]);
expectContainsEvery(scripts, ["handler", "fixture", "mock E2E"]);
});
test("keeps the Phase 2 plan files and progress synced with the implementation", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
// When, Then
expectContainsEvery(plan, [
"Modify: `src/app/App.tsx`, `src/main.tsx`, `vite.config.ts`, `playwright.config.ts`",
"Create: `src/shared/mocks/{browser,handlers,contract}.ts`",
"Create: `src/shared/mocks/__tests__/{mode-boundary,auth-handlers,mock-preview-docs,production-graph}.test.ts`",
"Create: `tests/e2e/{mock-mode-boundary,mock-preview-shell,server-mode-boundary}.spec.ts`",
"### Phase 2 구현·Gate 완료 기록 — 2026-07-27",
]);
});
});

View File

@@ -0,0 +1,58 @@
import { readFileSync } from "node:fs";
import packageJson from "../../../../package.json";
import { describe, expect, test } from "vitest";
const playwrightConfig = readFileSync("playwright.config.ts", "utf8");
describe("mock API mode scripts", () => {
test("keeps the default development server in server mode", () => {
// Given
const developmentScript = packageJson.scripts.dev;
// When
const startsServerMode = developmentScript === "VITE_API_MODE=server vite --host 127.0.0.1 --port 8888 --strictPort";
// Then
expect(startsServerMode).toBe(true);
});
test("provides explicit development and Playwright mock mode commands", () => {
// Given
const expectedDevelopmentMockScript = "VITE_API_MODE=mock vite --host 127.0.0.1 --port 8889 --strictPort";
const expectedE2eServerScript = "VITE_API_MODE=server playwright test";
const expectedE2eMockScript = "VITE_API_MODE=mock playwright test";
// When
const developmentMockScript = packageJson.scripts["dev:mock"];
const e2eServerScript = packageJson.scripts.e2e;
const e2eMockScript = packageJson.scripts["e2e:mock"];
// Then
expect(developmentMockScript).toBe(expectedDevelopmentMockScript);
expect(e2eServerScript).toBe(expectedE2eServerScript);
expect(e2eMockScript).toBe(expectedE2eMockScript);
});
test("keeps mode-specific E2E spec allowlists in Playwright config", () => {
// Given
const expectedServerSpecs = [
"**/server-mode-boundary.spec.ts",
"**/smoke.spec.ts",
"**/auth.spec.ts",
"**/accessibility-shell.spec.ts",
];
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"];
// When, Then
expectContainsEvery(playwrightConfig, expectedServerSpecs);
expectContainsEvery(playwrightConfig, expectedMockSpecs);
expect(playwrightConfig).toContain("testMatch");
});
});
function expectContainsEvery(source: string, tokens: readonly string[]): void {
for (const token of tokens) {
expect(source).toContain(token);
}
}

View File

@@ -0,0 +1,46 @@
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "vite";
import { describe, expect, test } from "vitest";
describe("production mock graph", () => {
test("excludes the browser mock module from the production bundle", async () => {
// Given
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
const previousNodeEnv = process.env.NODE_ENV;
try {
process.env.NODE_ENV = "production";
// When
await build({
build: { emptyOutDir: true, outDir },
configFile: "vite.config.ts",
logLevel: "silent",
mode: "production",
});
const outputFiles = collectFiles(outDir);
const output = outputFiles
.filter((filePath) => filePath.endsWith(".js"))
.map((filePath) => readFileSync(filePath, "utf8"))
.join("\n");
// Then
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
expect(output).not.toContain("mockServiceWorker.js");
expect(output).not.toContain("startMockWorker");
} finally {
process.env.NODE_ENV = previousNodeEnv;
rmSync(outDir, { force: true, recursive: true });
}
});
});
function collectFiles(directory: string): readonly string[] {
return readdirSync(directory).flatMap((entry) => {
const path = join(directory, entry);
return statSync(path).isDirectory() ? collectFiles(path) : [path];
});
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, test, vi } from "vitest";
const { setupWorkerMock, workerStart } = vi.hoisted(() => {
const workerStart = vi.fn();
const setupWorkerMock = vi.fn(() => ({ start: workerStart }));
return { setupWorkerMock, workerStart };
});
vi.mock("msw/browser", () => ({
setupWorker: setupWorkerMock,
}));
import { startMockWorker } from "./browser";
describe("startMockWorker", () => {
test("starts browser MSW with an error policy for unhandled requests", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
const startOptions = { onUnhandledRequest: "error" };
// When
await startMockWorker();
// Then
expect(setupWorkerMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything());
expect(workerStart).toHaveBeenCalledWith(startOptions);
});
});

View File

@@ -0,0 +1,11 @@
import { setupWorker } from "msw/browser";
import { getRuntimeEnv } from "@/shared/config/env";
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
let worker: ReturnType<typeof setupWorker> | null = null;
export async function startMockWorker(): Promise<void> {
worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl));
await worker.start({ onUnhandledRequest: "error" });
}

View File

@@ -0,0 +1,9 @@
import type { ApiErrorResponse, ApiSuccessResponse } from "@/shared/api/types";
export function ok<Data>(data: Data): ApiSuccessResponse<Data> {
return { success: true, message: null, data, errorProperty: null };
}
export function error(message: string): ApiErrorResponse {
return { success: false, message, data: null, errorProperty: null };
}

View File

@@ -0,0 +1,152 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { error, ok } from "@/shared/mocks/contract";
const adminToken = "mock-admin-jwt";
const memberToken = "mock-member-jwt";
const invalidRequestMessage = "잘못된 요청입니다.";
const missingCredentialMessage = "인증 정보가 없습니다.";
const accessDeniedMessage = "접근 권한이 없습니다.";
const unsupportedMediaTypeMessage = "지원하지 않는 미디어 타입입니다.";
const loginRequestSchema = z.strictObject({
email: z.email(),
password: z.string().min(1),
});
const aiCharactersPreview = {
totalCount: 0,
page: 0,
size: 20,
hasNext: false,
items: [],
} as const;
class MockStore {
readonly #revokedTokens = new Set<string>();
activate(token: string): void {
this.#revokedTokens.delete(token);
}
revoke(token: string): void {
this.#revokedTokens.add(token);
}
getTokenAccess(token: string): "admin" | "denied" | "unauthorized" {
if (token === adminToken && !this.#revokedTokens.has(token)) {
return "admin";
}
if (token === memberToken) {
return "denied";
}
return "unauthorized";
}
}
export type MockFixtureStore = MockStore;
export function createMockStore(): MockFixtureStore {
return new MockStore();
}
function getBearerToken(request: Request): string | null {
const authorization = request.headers.get("Authorization");
const prefix = "Bearer ";
return authorization?.startsWith(prefix) ? authorization.slice(prefix.length) : null;
}
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function accessResponse(store: MockFixtureStore, request: Request): Response | null {
const token = getBearerToken(request);
if (token === null) {
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
}
const tokenAccess = store.getTokenAccess(token);
if (tokenAccess === "admin") {
return null;
}
if (tokenAccess === "denied") {
return HttpResponse.json(error(accessDeniedMessage), { status: 403 });
}
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
}
async function parseLoginRequest(request: Request): Promise<boolean> {
if (request.headers.has("Authorization")) {
return false;
}
try {
return loginRequestSchema.safeParse(await request.json()).success;
} catch (parseError) {
if (parseError instanceof SyntaxError) {
return false;
}
throw parseError;
}
}
export function createMockHandlers(
store: MockFixtureStore,
apiBaseUrl: string,
): readonly RequestHandler[] {
return [
http.post(endpointUrl(apiBaseUrl, "/admin/member/login"), async ({ request }) => {
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
return HttpResponse.json(error(unsupportedMediaTypeMessage), { status: 415 });
}
if (!(await parseLoginRequest(request))) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
store.activate(adminToken);
return HttpResponse.json(ok({ token: adminToken, role: "ADMIN" }));
}),
http.post(endpointUrl(apiBaseUrl, "/member/logout"), async ({ request }) => {
const token = getBearerToken(request);
if (token === null) {
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
}
const tokenAccess = store.getTokenAccess(token);
if (tokenAccess === "denied") {
return HttpResponse.json(error(accessDeniedMessage), { status: 403 });
}
if (tokenAccess !== "admin") {
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
}
if ((await request.text()) !== "") {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
store.revoke(token);
return HttpResponse.json(ok({}));
}),
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters"), ({ request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const url = new URL(request.url);
if (url.searchParams.get("page") !== "0" || url.searchParams.get("size") !== "20") {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(aiCharactersPreview));
}),
];
}