feat(ai-character): 리소스 관리 기반 정비

This commit is contained in:
Yu Sung
2026-08-01 01:30:24 +09:00
parent 55ba0df77a
commit a1cae336d9
73 changed files with 5162 additions and 816 deletions

View File

@@ -12,10 +12,7 @@ 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()),
content: z.array(z.unknown()),
});
function createClient(token: string | null = adminToken) {
@@ -179,7 +176,7 @@ describe("mock auth handlers", () => {
expect(parsedFreshResponse).toEqual({
success: true,
message: null,
data: { totalCount: 0, page: 0, size: 20, hasNext: false, items: [] },
data: { totalCount: 2, content: expect.arrayContaining([expect.objectContaining({ name: "루나" })]) },
errorProperty: null,
});
});

View File

@@ -0,0 +1,177 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { createApiResponseSchema } from "@/shared/api/types";
import { audioContentDetailSchema, audioContentListResponseSchema } from "@/features/audio-contents/model/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 characterDetailPreviewSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
systemPrompt: z.string(),
isActive: z.boolean(),
});
const aiCharactersPreviewSchema = z.object({
totalCount: z.number(),
content: z.array(z.object({ id: z.number(), name: z.string() })),
});
const originalWorkPreviewSchema = z.object({ id: z.number(), title: z.string(), imageUrl: z.string().nullable() });
const originalWorkSearchItemSchema = z.object({
id: z.number().int(),
title: z.string(),
contentType: z.string(),
category: z.string(),
isAdult: z.boolean(),
description: z.string(),
originalWork: z.string().nullable(),
originalLink: z.string().nullable(),
writer: z.string().nullable(),
studio: z.string().nullable(),
originalLinks: z.array(z.string()),
tags: z.array(z.string()),
imageUrl: z.string().nullable(),
});
const nullSuccessSchema = z.null();
function requireData<Data>(data: Data | null): Data {
if (data === null) {
throw new Error("response data missing");
}
return data;
}
function useMockHandlers() {
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
}
function authorizedFetch(path: string, init: RequestInit = {}) {
return fetch(`${apiBaseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${adminToken}`, ...init.headers },
});
}
function mutationInit(request: object): RequestInit {
const boundary = "test-boundary";
return {
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
body: `--${boundary}\r\nContent-Disposition: form-data; name="request"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(request)}\r\n--${boundary}--\r\n`,
};
}
describe("mock character handlers", () => {
test("P10-T1 original work search handler serves v2 full DTO and rejects missing searchTerm", async () => {
// Given
useMockHandlers();
// When
const missingResponse = await authorizedFetch("/api/v2/admin/ai-characters/original-works/search");
const searchResponse = await authorizedFetch("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%8B%AC%EB%B9%9B");
// Then
expect(missingResponse.status).toBe(400);
expect(requireData(createApiResponseSchema(z.array(originalWorkSearchItemSchema)).parse(await searchResponse.json()).data)).toEqual([
expect.objectContaining({
id: 7,
title: "달빛 상담소",
contentType: "WEBTOON",
category: "힐링",
isAdult: false,
originalWork: "Moonlight Office",
originalLinks: ["https://example.com/moonlight"],
tags: ["상담", "힐링"],
}),
]);
});
test("create, update, and deactivate mutate list and detail contract responses", async () => {
// Given
useMockHandlers();
// When
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters", {
method: "POST",
...mutationInit({ name: "노아", systemPrompt: "명확하게 답한다.", description: "새 안내형 캐릭터" }),
});
const createdListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
const createdList = requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await createdListResponse.json()).data);
const created = createdList.content.find((character) => character.name === "노아");
if (created === undefined) {
throw new Error("created character missing from list");
}
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
method: "PUT",
...mutationInit({ name: "노아 수정", systemPrompt: "짧게 답한다.", description: "수정된 안내형 캐릭터" }),
});
const updatedDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
const deactivateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
method: "PUT",
...mutationInit({ isActive: false }),
});
const finalListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
// Then
expect(createApiResponseSchema(nullSuccessSchema).parse(await createResponse.json()).data).toBeNull();
expect(createApiResponseSchema(nullSuccessSchema).parse(await updateResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(characterDetailPreviewSchema).parse(await updatedDetailResponse.json()).data)).toEqual({
id: created.id,
name: "노아 수정",
description: "수정된 안내형 캐릭터",
systemPrompt: "짧게 답한다.",
isActive: true,
});
expect(createApiResponseSchema(nullSuccessSchema).parse(await deactivateResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await finalListResponse.json()).data).content).not.toContainEqual(expect.objectContaining({ id: created.id }));
});
test("P10-T1 create and update persist selected originalWorkId and clear it when omitted", async () => {
// Given
useMockHandlers();
// When
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters", {
method: "POST",
...mutationInit({ name: "원작 캐릭터", systemPrompt: "명확하게 답한다.", description: "원작 연결", originalWorkId: 7 }),
});
const createdListResponse = await authorizedFetch("/api/v2/admin/ai-characters?page=0&size=20");
const createdList = requireData(createApiResponseSchema(aiCharactersPreviewSchema).parse(await createdListResponse.json()).data);
const created = createdList.content.find((character) => character.name === "원작 캐릭터");
if (created === undefined) {
throw new Error("created character missing from list");
}
const createdDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`, {
method: "PUT",
...mutationInit({ name: "원작 해제 캐릭터", systemPrompt: "짧게 답한다.", description: "원작 해제" }),
});
const updatedDetailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/${created.id}`);
// Then
expect(createApiResponseSchema(nullSuccessSchema).parse(await createResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(z.object({ originalWork: originalWorkPreviewSchema.nullable() })).parse(await createdDetailResponse.json()).data).originalWork).toEqual({ id: 7, title: "달빛 상담소", imageUrl: null });
expect(createApiResponseSchema(nullSuccessSchema).parse(await updateResponse.json()).data).toBeNull();
expect(requireData(createApiResponseSchema(z.object({ originalWork: originalWorkPreviewSchema.nullable() })).parse(await updatedDetailResponse.json()).data).originalWork).toBeNull();
});
test("audio content mock handlers expose contract-shaped list and detail without timezone", async () => {
// Given
useMockHandlers();
// When
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents?search_word=달빛&page=0&size=20");
const detailResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001");
// Then
const list = requireData(createApiResponseSchema(audioContentListResponseSchema).parse(await listResponse.json()).data);
const detail = requireData(createApiResponseSchema(audioContentDetailSchema).parse(await detailResponse.json()).data);
expect(list).toMatchObject({ totalCount: 1, items: [{ audioContentId: 9001, title: "달빛 상담 오디오", coverImageUrl: expect.stringMatching(/^data:image\//) }] });
expect(detail).toMatchObject({ contentId: 9001, title: "달빛 상담 오디오", coverImageUrl: expect.stringMatching(/^data:image\//), contentUrl: expect.stringMatching(/^data:audio\//), duration: "00:01" });
});
});

View File

@@ -19,6 +19,97 @@ function expectContainsEvery(source: string, tokens: readonly string[]): void {
}
}
function sectionBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return source.slice(startIndex, endIndex);
}
function sectionAtHeading(source: string, heading: string): string {
const headingLevel = /^(#{1,6})\s/.exec(heading)?.[1].length ?? 0;
const lines = source.split("\n");
const startIndex = lines.findIndex((line) => line === heading);
expect(headingLevel).toBeGreaterThan(0);
expect(startIndex).toBeGreaterThanOrEqual(0);
const endIndex = lines.findIndex((line, index) => {
const level = /^(#{1,6})\s/.exec(line)?.[1].length ?? 0;
return index > startIndex && level > 0 && level <= headingLevel;
});
return lines.slice(startIndex, endIndex === -1 ? undefined : endIndex).join("\n");
}
function progressRecordAtMarker(source: string, marker: string): string {
const lines = source.split("\n");
let startIndex = lines.length - 1;
while (startIndex >= 0 && lines[startIndex] !== marker) {
startIndex -= 1;
}
expect(startIndex).toBeGreaterThanOrEqual(0);
const endIndex = lines.findIndex((line, index) => index > startIndex && (/^\*\*.+:\*\*$/.test(line) || /^#{2,6}\s/.test(line)));
return lines.slice(startIndex, endIndex === -1 ? undefined : endIndex).join("\n");
}
function latestProgressRecord(source: string): string {
const progressLog = sectionAtHeading(source, "## 7. 검증 기록");
const markers = progressLog.split("\n").filter((line) => /^\*\*.+ — \d{4}-\d{2}-\d{2}:\*\*$/.test(line));
const marker = markers[markers.length - 1];
expect(marker).toBeDefined();
return progressRecordAtMarker(progressLog, marker ?? "");
}
function latestSectionAtLevel(source: string, level: number): string {
const lines = source.split("\n");
const headings: { index: number; level: number }[] = [];
let fence: { character: string; length: number } | undefined;
for (const [index, line] of lines.entries()) {
const fenceMarker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
if (fence) {
const closingFence = line.trim();
const { character, length } = fence;
if (closingFence.length >= length && [...closingFence].every((closingCharacter) => closingCharacter === character)) {
fence = undefined;
}
continue;
}
if (fenceMarker) {
fence = { character: fenceMarker[0] ?? "", length: fenceMarker.length };
continue;
}
const headingLevel = /^(#{1,6})\s/.exec(line)?.[1].length;
if (headingLevel) {
headings.push({ index, level: headingLevel });
}
}
const matchingHeadings = headings.filter((heading) => heading.level === level);
const startIndex = matchingHeadings[matchingHeadings.length - 1]?.index ?? -1;
const endIndex = headings.find((heading) => heading.index > startIndex && heading.level <= level)?.index;
expect(startIndex).toBeGreaterThanOrEqual(0);
return lines.slice(startIndex, endIndex).join("\n");
}
function matchingLines(source: string, token: string): readonly string[] {
return source.split("\n").filter((line) => line.includes(token));
}
describe("mock preview documentation", () => {
test("documents the actual npm scripts and mode boundary in README", () => {
// Given
@@ -29,11 +120,11 @@ describe("mock preview documentation", () => {
`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, ["npm run e2e:mock", "Chromium/mobile Chrome matrix", "file filter나 `--project` 인자"]);
expectContainsEvery(readme, ["server mode", "mock mode", "VITE_API_MODE=server", "VITE_API_MODE=mock"]);
expectContainsEvery(readme, ["mock data reset", "production", "no-auto-fallback"]);
});
@@ -47,9 +138,150 @@ describe("mock preview documentation", () => {
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, ["Chromium/mobile Chrome matrix", "file filter나 `--project` 인자"]);
expectContainsEvery(scripts, ["handler", "fixture", "mock E2E"]);
});
test("keeps browser support docs synced with Playwright projects", () => {
// Given
const readme = projectFile("README.md");
const playwrightConfig = projectFile("playwright.config.ts");
const prd = projectFile("docs/20260725_AI캐릭터관리자웹/prd.md");
const decisionLog = sectionAtHeading(prd, "## 16. 결정 기록");
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
const chromeOnlyDecisions = matchingLines(decisionLog, "Chromium/mobile Chrome");
// When, Then
expectContainsEvery(playwrightConfig, ["name: \"chromium\"", "name: \"mobile-chrome\""]);
expect(playwrightConfig).not.toContain("name: \"webkit\"");
expect(playwrightConfig).not.toContain("name: \"mobile-safari\"");
expectContainsEvery(readme, ["데스크톱 Chrome", "모바일 Chrome", "Chromium/mobile Chrome"]);
expectContainsEvery(prd, ["데스크톱 Chrome", "모바일 Chrome", "Chromium/mobile Chrome"]);
expect(chromeOnlyDecisions).toHaveLength(1);
expectContainsEvery(chromeOnlyDecisions[0] ?? "", ["사용자 직접 지시", "Chrome 2종", "테스트 시간"]);
expectContainsEvery(plan, ["Chromium/mobile Chrome", "WebKit·Mobile Safari는 지원 범위에서 제외"]);
const syntheticDecisionLog = sectionAtHeading("## 16. 결정 기록\n- stale\n\n## 17. 후속 기록\n- Chromium/mobile Chrome 사용자 직접 지시 Chrome 2종 테스트 시간\n", "## 16. 결정 기록");
expect(syntheticDecisionLog).not.toContain("Chrome 2종");
});
test("keeps current Phase 9 Gate docs aligned with Chromium-only projects", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
const phase9Review = projectFile("docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md");
const p9R8Task = sectionBetween(plan, "### Task R9.8", "**P9-R8 수정 검증 기록");
const p9R9Task = sectionBetween(plan, "### Task R9.9", "**P9-R9 수정 검증 기록");
const p9R10Task = sectionBetween(plan, "### Task R9.10", "**P9-R10 수정 검증 기록");
const p9R17Task = sectionAtHeading(plan, "### Task R9.17 — Phase 9 current metadata·최신 결론 동기화");
const p9R18Task = sectionAtHeading(plan, "### Task R9.18 — Phase 9 finding·checklist 상태 종결 contract");
const p9R19Task = sectionAtHeading(plan, "### Task R9.19 — fenced code 내부 가짜 H2 배제");
const p9R12Review = sectionBetween(phase9Review, "## 20. P9-R11 수정 결과 재점검", "## 21. P9-R12 수정 결과 재점검");
const p9R17Finding = sectionAtHeading(phase9Review, "### `REV-P9-017` — Phase 9 current metadata가 완료된 P10-R14를 후속으로 유지함");
const p9R18Finding = sectionAtHeading(phase9Review, "### `REV-P9-018` — 완료 결론과 소유 finding·Task checklist 범위가 불일치함");
const p9R19Finding = sectionAtHeading(phase9Review, "### `REV-P9-019` — 최신 H2 helper가 fenced heading과 동일 제목을 오인함");
const phase9Metadata = sectionAtHeading(phase9Review, "## 1. 리뷰 정보");
const latestPhase9Review = latestSectionAtLevel(phase9Review, 2);
const latestPhase9Conclusion = sectionAtHeading(latestPhase9Review, "### 종료 판정");
// When, Then
expectContainsEvery(p9R9Task, ["tests/e2e/server-mode-boundary.spec.ts"]);
expectContainsEvery(plan, ["Chromium/mobile Chrome"]);
expect(`${p9R8Task}\n${p9R9Task}\n${p9R10Task}`).not.toMatch(/--project=webkit|mobile-safari|mock matrix 4 projects|4-project 분할 script|server-boundary\.spec\.ts/);
expect(p9R10Task).not.toContain("자동 WebKit harness");
expect(p9R17Task).not.toContain("- [ ]");
expect(`${p9R18Task}\n${p9R19Task}`).not.toContain("- [ ]");
expect(p9R12Review).toContain("`REV-P9-012`/`P9-R12` 수정 완료");
expectContainsEvery(p9R17Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p9R18Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p9R19Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(phase9Metadata, ["`REV-P9-018`~`REV-P9-019`", "`P9-R18`~`P9-R19` 수정 완료"]);
expect(phase9Metadata).not.toContain("P10-R16");
expectContainsEvery(latestPhase9Review, ["`REV-P9-018`~`REV-P9-019`", "`P9-R18`~`P9-R19` 수정 완료", "Chromium/mobile Chrome 2-project"]);
expectContainsEvery(latestPhase9Conclusion, ["자동 보완 Task는 완료", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(latestPhase9Conclusion).not.toMatch(/4-browser matrix|WebKit\/Mobile Safari 미실행을 남은 위험|4-project|후속 goal 필요|후속 필요|다음 자동 보완/);
const syntheticConclusion = sectionAtHeading("### 종료 판정\n- stale\n\n### 후속 판정\n- `REV-P9-013`/`P9-R13` 수정 완료\n", "### 종료 판정");
expect(syntheticConclusion).not.toContain("수정 완료");
const syntheticHeading = sectionAtHeading("## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31 stale\n- stale\n\n## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31\n- current\n", "## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31");
expect(syntheticHeading).not.toContain("stale");
expect(syntheticHeading).toContain("current");
const syntheticLatestReview = latestSectionAtLevel("## 24. 과거\n- 후속 필요\n\n## 25. 현재\n### 종료 판정\n- `REV-P9-017`/`P9-R17` 수정 완료\n", 2);
expect(syntheticLatestReview).not.toContain("후속 필요");
});
test("ignores fenced code headings when finding the latest review H2", () => {
const latestReview = latestSectionAtLevel("## 27. 실제 최신 리뷰\n- current\n\n```md\n## 98. backtick 예시\n```\n\n~~~md\n## 99. tilde 예시\n~~~\n", 2);
expect(latestReview).toMatch(/^## 27\. 실제 최신 리뷰/);
});
test("returns the last review H2 when exact headings are repeated", () => {
const latestReview = latestSectionAtLevel("## 동일 제목\n- stale\n\n## 동일 제목\n- current\n", 2);
expect(latestReview).toContain("current");
expect(latestReview).not.toContain("stale");
});
test("selects the actual latest independent Progress record", () => {
const progressLog = "## 7. 검증 기록\n\n**동일 기록 — 2026-08-01:**\n- 자동 보완 완료\n\n**동일 기록 — 2026-08-01:**\n- 현재 재점검 기록\n- 보완 필요\n";
const latestProgress = latestProgressRecord(progressLog);
expect(latestProgress).toContain("현재 재점검 기록");
expect(latestProgress).toContain("보완 필요");
expect(latestProgress).not.toContain("자동 보완 완료");
});
test("keeps Phase 10 current state scoped to completed automatic remediation and manual QA", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
const phase10Review = projectFile("docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md");
const documentStatus = sectionBetween(plan, "| 문서 항목 | 내용 |", "## 목표");
const currentState = sectionAtHeading(plan, "## 현재 상태");
const topProgress = sectionAtHeading(plan, "## Progress");
const executionOrder = sectionAtHeading(plan, "## 실행 순서와 의존성");
const findings = sectionAtHeading(plan, "## 발견된 문제");
const p9R16Task = sectionBetween(plan, "### Task R9.16", "**P9-R16 수정 검증 기록");
const p10R14Task = sectionBetween(plan, "### Task R10.14", "**P10-R14 수정 검증 기록");
const p10R15Task = sectionAtHeading(plan, "### Task R10.15 — §7 최신 Progress와 실제 marker scope 복구");
const p10R16Task = sectionAtHeading(plan, "### Task R10.16 — Phase 10 finding·checklist·최신 Progress contract 종결");
const p10R17Task = sectionAtHeading(plan, "### Task R10.17 — 중복 Progress marker의 마지막 record 보장");
const p10R13Progress = progressRecordAtMarker(plan, "**P10-R13 수정 검증 기록 — 2026-07-31:**");
const latestProgress = latestProgressRecord(plan);
const p10R16Finding = sectionAtHeading(phase10Review, "### `REV-P10-016` — contract가 §7 최신 Progress 대신 Task-local 기록을 검사함");
const p10R17Finding = sectionAtHeading(phase10Review, "### `REV-P10-017` — 완료 finding·Task와 실제 최신 §7 Progress가 contract에서 누락됨");
const p10R18Finding = sectionAtHeading(phase10Review, "### `REV-P10-018` — 동일 Progress marker 반복 시 첫 record를 다시 선택함");
const phase10Metadata = sectionAtHeading(phase10Review, "## 1. 리뷰 정보");
const latestReview = latestSectionAtLevel(phase10Review, 2);
const latestConclusion = sectionAtHeading(latestReview, "### 종료 판정");
// When, Then
const currentStateTokens = ["자동 보완 완료", "P9-R18", "P9-R19", "P10-R16", "P10-R17", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"];
expectContainsEvery(documentStatus, currentStateTokens);
expectContainsEvery(currentState, currentStateTokens);
expectContainsEvery(topProgress, currentStateTokens);
expectContainsEvery(executionOrder, ["자동 보완 Task는 완료", "Chromium/mobile Chrome"]);
expectContainsEvery(findings, ["P9-R18", "P9-R19", "P10-R16", "P10-R17", "보완했다", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(`${documentStatus}\n${currentState}\n${executionOrder}\n${findings}`).not.toMatch(/보완 필요|다음 실행 순서/);
expect(`${p9R16Task}\n${p10R14Task}\n${p10R15Task}\n${p10R16Task}\n${p10R17Task}`).not.toContain("- [ ]");
expectContainsEvery(p10R13Progress, ["P9-R14", "P9-R15", "P10-R13", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(p10R13Progress).not.toMatch(/P9-R16|P10-R14|남은 항목: `P9-R14`|`P9-R14` → `P9-R15` → `P10-R13`|보완 필요/);
expectContainsEvery(latestProgress, ["P9-R18", "P9-R19", "P10-R16", "P10-R17", "자동 보완 Task는 완료", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
expect(latestProgress).not.toMatch(/P9-R16` → `P10-R14|후속 필요|보완 필요/);
expectContainsEvery(p10R16Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p10R17Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(p10R18Finding, ["| 상태 | 수정 완료 |", "2026-08-01"]);
expectContainsEvery(phase10Metadata, ["`REV-P10-018`/`P10-R17` 수정 완료"]);
expectContainsEvery(latestReview, ["`REV-P10-018`/`P10-R17` 수정 완료", "마지막 동일 marker occurrence"]);
expectContainsEvery(latestConclusion, ["`REV-P10-018`/`P10-R17` 수정 완료", "자동 보완 Task는 완료", "실제 crop pixel", "stale ADMIN", "Series/FanTalk/Comments/file policy"]);
const syntheticProgress = progressRecordAtMarker("**P10-R13 수정 검증 기록 — 2026-07-31:**\n- stale\n\n**P10-R14 수정 검증 기록 — 2026-07-31:**\n- 자동 보완 완료\n", "**P10-R13 수정 검증 기록 — 2026-07-31:**");
expect(syntheticProgress).not.toContain("자동 보완 완료");
const syntheticReview = sectionAtHeading("## 25. P10-R14 수정 후 검증 — 2026-07-31\n### 검토 범위\n- `REV-P10-015`/`P10-R14` 수정 완료\n\n### 종료 판정\n- stale\n", "## 25. P10-R14 수정 후 검증 — 2026-07-31");
expect(sectionAtHeading(syntheticReview, "### 종료 판정")).not.toContain("수정 완료");
const syntheticLatestReview = latestSectionAtLevel("## 25. 과거\n### 종료 판정\n- 후속 필요\n\n## 26. 현재\n### 종료 판정\n- `REV-P10-016`/`P10-R15` 수정 완료\n", 2);
expect(syntheticLatestReview).not.toContain("후속 필요");
});
test("keeps the Phase 2 plan files and progress synced with the implementation", () => {
// Given
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");

View File

@@ -21,7 +21,6 @@ describe("mock API mode scripts", () => {
// 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"];
@@ -31,7 +30,12 @@ describe("mock API mode scripts", () => {
// Then
expect(developmentMockScript).toBe(expectedDevelopmentMockScript);
expect(e2eServerScript).toBe(expectedE2eServerScript);
expect(e2eMockScript).toBe(expectedE2eMockScript);
expect(e2eMockScript).toContain("VITE_API_MODE=mock playwright test \"$@\"");
expect(e2eMockScript).toContain("npm run e2e:mock:chromium");
expect(e2eMockScript).toContain("npm run e2e:mock:mobile-chrome");
expect(e2eMockScript).not.toContain("webkit");
expect(e2eMockScript).not.toContain("mobile-safari");
expect(packageJson.scripts["e2e:mock:raw"]).toBe("VITE_API_MODE=mock playwright test");
});
test("keeps mode-specific E2E spec allowlists in Playwright config", () => {
@@ -41,8 +45,9 @@ describe("mock API mode scripts", () => {
"**/smoke.spec.ts",
"**/auth.spec.ts",
"**/accessibility-shell.spec.ts",
"**/series.spec.ts",
];
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"];
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts", "**/character-workspace.spec.ts", "**/audio-content.spec.ts", "**/series.spec.ts", "**/community.spec.ts", "**/fan-talk.spec.ts", "**/resource-workflows.spec.ts", "**/error-mapping.spec.ts", "**/responsive-capabilities.spec.ts", "**/accessibility.spec.ts"];
// When, Then
expectContainsEvery(playwrightConfig, expectedServerSpecs);

View File

@@ -0,0 +1,126 @@
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
function writeAscii(bytes: Uint8Array, offset: number, value: string): void {
for (let index = 0; index < value.length; index += 1) {
bytes[offset + index] = value.charCodeAt(index);
}
}
function createOneSecondWavDataUrl(): string {
const sampleRate = 8_000;
const bytesPerSample = 2;
const dataSize = sampleRate * bytesPerSample;
const bytes = new Uint8Array(44 + dataSize);
const view = new DataView(bytes.buffer);
writeAscii(bytes, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(bytes, 8, "WAVEfmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * bytesPerSample, true);
view.setUint16(32, bytesPerSample, true);
view.setUint16(34, 16, true);
writeAscii(bytes, 36, "data");
view.setUint32(40, dataSize, true);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return `data:audio/wav;base64,${btoa(binary)}`;
}
export const previewAudioUrl = createOneSecondWavDataUrl();
const previewCoverImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23D9F6FF'/%3E%3Cstop offset='1' stop-color='%2300BDF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='20' fill='url(%23g)'/%3E%3Ccircle cx='64' cy='64' r='30' fill='%23FFFFFF' fill-opacity='.72'/%3E%3Cpath d='M52 48v32l30-16z' fill='%23062B36'/%3E%3C/svg%3E";
export const mockAudioContentThemes = [
{ id: 7, theme: "힐링", image: previewCoverImageUrl },
{ id: 8, theme: "안내", image: previewCoverImageUrl },
] as const satisfies readonly AudioContentTheme[];
export const mockAudioContentListItems = [
{
audioContentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
coverImageUrl: previewCoverImageUrl,
creatorNickname: "루나",
theme: "힐링",
price: 1000,
totalContentCount: 5,
remainingContentCount: 4,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
remainingTime: "7일",
contentUrl: previewAudioUrl,
date: "2026-07-28T01:00:00Z",
releaseDate: "2026-07-28T01:00:00Z",
tags: "상담,힐링",
},
{
audioContentId: 9002,
title: "아침 안내 오디오",
detail: "하루를 시작하는 안내",
coverImageUrl: previewCoverImageUrl,
creatorNickname: "루나",
theme: "안내",
price: 0,
totalContentCount: null,
remainingContentCount: null,
isAdult: false,
isPointAvailable: false,
isCommentAvailable: false,
remainingTime: "",
contentUrl: previewAudioUrl,
date: "2026-07-27 09:00:00",
releaseDate: null,
tags: "안내",
},
] as const satisfies readonly AudioContentListItem[];
export const mockAudioContentDetails = [
{
contentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
languageCode: "ko",
coverImageUrl: previewCoverImageUrl,
contentUrl: previewAudioUrl,
themeStr: "힐링",
tag: "상담,힐링",
price: 1000,
duration: "00:01",
releaseDate: "2026-07-28T01:00:00Z",
totalContentCount: 5,
remainingContentCount: 4,
orderSequence: 1,
isActivePreview: true,
isAdult: false,
isMosaic: false,
isOnlyRental: false,
existOrdered: false,
purchaseOption: "RENT_ONLY",
orderType: null,
remainingTime: "7일",
creatorOtherContentList: [],
sameThemeOtherContentList: [],
isCommentAvailable: true,
isLike: false,
likeCount: 0,
commentList: [],
commentCount: 0,
isPin: false,
isAvailablePin: false,
creator: { creatorId: 101, nickname: "루나", profileImageUrl: "https://cdn.example.com/mock/luna.png", isFollowing: false, isFollow: false, isNotify: false },
previousContent: null,
nextContent: null,
buyerList: [],
isAvailableUsePoint: true,
translated: null,
},
] as const satisfies readonly AudioContentDetail[];

View File

@@ -0,0 +1,133 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type AudioContentStore = {
readonly createAudioContent: (characterId: string, request: AudioContentCreateRequest) => number | null;
readonly deactivateAudioContent: (characterId: string, contentId: string, request: AudioContentDeactivateRequest) => boolean;
readonly getAudioContent: (characterId: string, contentId: string) => unknown | null;
readonly listAudioContentThemes: () => readonly AudioContentTheme[];
readonly listAudioContents: (characterId: string, searchWord: string | null, page: number, size: number) => unknown | null;
readonly updateAudioContent: (characterId: string, contentId: string, request: AudioContentUpdateRequest) => boolean;
};
type AudioContentMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseCreateRequest: (request: Request) => Promise<AudioContentCreateRequest | null>;
readonly parseDeactivateRequest: (request: Request) => Promise<AudioContentDeactivateRequest | null>;
readonly parseUpdateRequest: (request: Request) => Promise<AudioContentUpdateRequest | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly searchWord: string | null; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const searchWord = url.searchParams.get("search_word");
if (!Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1 || (searchWord !== null && searchWord.trim().length < 2)) {
return null;
}
return { page, searchWord, size };
}
export function createAudioContentMockHandlers(store: AudioContentStore, accessResponse: AccessResponse, options: AudioContentMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/audio-content-themes"), ({ request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
return HttpResponse.json(ok(store.listAudioContentThemes()));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const audioContents = store.listAudioContents(characterId, query.searchWord, query.page, query.size);
if (audioContents === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(audioContents));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await options.parseCreateRequest(request);
if (mutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = store.createAudioContent(characterId, mutation);
if (contentId === null) {
return HttpResponse.json(error("AI 캐릭터 또는 오디오 테마를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok({ contentId }));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = typeof params.contentId === "string" ? params.contentId : "";
const audioContent = store.getAudioContent(characterId, contentId);
if (audioContent === null) {
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(audioContent));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const updateMutation = await options.parseUpdateRequest(request.clone());
if (updateMutation !== null) {
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = typeof params.contentId === "string" ? params.contentId : "";
if (!store.updateAudioContent(characterId, contentId, updateMutation)) {
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}
const deactivateMutation = await options.parseDeactivateRequest(request);
if (deactivateMutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const contentId = typeof params.contentId === "string" ? params.contentId : "";
if (!store.deactivateAudioContent(characterId, contentId, deactivateMutation)) {
return HttpResponse.json(error("오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,228 @@
import { z } from "zod";
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
import { audioContentCreateRequestSchema, audioContentDeactivateRequestSchema, audioContentUpdateRequestSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import type { CharacterDetail } from "@/features/characters/model/types";
import { mockAudioContentDetails, mockAudioContentListItems, mockAudioContentThemes } from "@/shared/mocks/audio-content-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export class AudioContentMockStore {
#audioContentDetails: AudioContentDetail[] = mockAudioContentDetails.map(toMutableAudioDetail);
#audioContents: AudioContentListItem[] = mockAudioContentListItems.map(toMutableAudioListItem);
#nextAudioContentId = 9300;
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listAudioContents(characterId: string, searchWord: string | null, page: number, size: number) {
if (this.#getCharacter(characterId) === null) {
return null;
}
const normalizedSearch = searchWord?.trim().toLowerCase() ?? "";
const filteredAudioContents = normalizedSearch.length === 0
? this.#audioContents
: this.#audioContents.filter((audio) => `${audio.title} ${audio.detail} ${audio.theme} ${audio.tags}`.toLowerCase().includes(normalizedSearch));
const start = page * size;
return {
totalCount: filteredAudioContents.length,
items: filteredAudioContents.slice(start, start + size),
};
}
getAudioContent(characterId: string, contentId: string) {
if (this.#getCharacter(characterId) === null) {
return null;
}
return this.#audioContentDetails.find((audio) => String(audio.contentId) === contentId) ?? null;
}
listAudioContentThemes(): readonly AudioContentTheme[] {
return mockAudioContentThemes;
}
createAudioContent(characterId: string, request: AudioContentCreateRequest): number | null {
const character = this.#getCharacter(characterId);
if (character === null) {
return null;
}
const theme = mockAudioContentThemes.find((item) => item.id === request.themeId);
if (theme === undefined) {
return null;
}
this.#nextAudioContentId += 1;
const contentId = this.#nextAudioContentId;
const listItem: AudioContentListItem = {
audioContentId: contentId,
title: request.title,
detail: request.detail,
coverImageUrl: theme.image,
creatorNickname: character.name,
theme: theme.theme,
price: request.price,
totalContentCount: request.limited,
remainingContentCount: request.limited,
isAdult: request.isAdult,
isPointAvailable: request.isPointAvailable,
isCommentAvailable: request.isCommentAvailable,
remainingTime: "",
contentUrl: mockAudioContentListItems[0].contentUrl,
date: "2026-07-28 12:00:00",
releaseDate: request.releaseDate,
tags: request.tags,
};
const detail: AudioContentDetail = {
...mockAudioContentDetails[0],
contentId,
title: request.title,
detail: request.detail,
languageCode: request.languageCode,
coverImageUrl: theme.image,
contentUrl: listItem.contentUrl,
themeStr: theme.theme,
tag: request.tags,
price: request.price,
releaseDate: request.releaseDate,
totalContentCount: request.limited,
remainingContentCount: request.limited,
isAdult: request.isAdult,
purchaseOption: request.purchaseOption,
isCommentAvailable: request.isCommentAvailable,
isAvailableUsePoint: request.isPointAvailable,
creator: { ...mockAudioContentDetails[0].creator, creatorId: character.id, nickname: character.name },
};
this.#audioContents = [...this.#audioContents, listItem];
this.#audioContentDetails = [...this.#audioContentDetails, detail];
return contentId;
}
updateAudioContent(characterId: string, contentId: string, request: AudioContentUpdateRequest): boolean {
if (this.#getCharacter(characterId) === null) {
return false;
}
const current = this.getAudioContent(characterId, contentId);
if (current === null) {
return false;
}
const nextDetail: AudioContentDetail = {
...current,
title: request.title ?? current.title,
detail: request.detail ?? current.detail,
tag: request.tags ?? current.tag,
price: request.price ?? current.price,
isAdult: request.isAdult ?? current.isAdult,
isCommentAvailable: request.isCommentAvailable ?? current.isCommentAvailable,
isAvailableUsePoint: request.isPointAvailable ?? current.isAvailableUsePoint,
};
this.#audioContentDetails = this.#audioContentDetails.map((audio) => (audio.contentId === nextDetail.contentId ? nextDetail : audio));
this.#audioContents = this.#audioContents.map((audio) => (String(audio.audioContentId) === contentId ? {
...audio,
title: nextDetail.title,
detail: nextDetail.detail,
tags: nextDetail.tag,
price: nextDetail.price,
isAdult: nextDetail.isAdult,
isCommentAvailable: nextDetail.isCommentAvailable,
isPointAvailable: nextDetail.isAvailableUsePoint,
} : audio));
return true;
}
deactivateAudioContent(characterId: string, contentId: string, request: AudioContentDeactivateRequest): boolean {
if (!request.isActive) {
if (this.#getCharacter(characterId) === null) {
return false;
}
const current = this.getAudioContent(characterId, contentId);
if (current === null) {
return false;
}
this.#audioContents = this.#audioContents.filter((audio) => String(audio.audioContentId) !== contentId);
return true;
}
return false;
}
}
export async function parseAudioContentCreateRequest(request: Request): Promise<AudioContentCreateRequest | null> {
try {
const body = await request.text();
if (!body.includes('name="contentFile"') || !body.includes('name="coverImage"') || body.includes('name="audioFile"')) {
return null;
}
return audioContentCreateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
return null;
}
throw parseError;
}
}
export async function parseAudioContentUpdateRequest(request: Request): Promise<AudioContentUpdateRequest | null> {
try {
const body = await request.text();
if (body.includes('name="contentFile"') || body.includes('name="audioFile"')) {
return null;
}
return audioContentUpdateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
return null;
}
throw parseError;
}
}
export async function parseAudioContentDeactivateRequest(request: Request): Promise<AudioContentDeactivateRequest | null> {
try {
const body = await request.text();
return audioContentDeactivateRequestSchema.parse(JSON.parse(extractMultipartJsonRequest(body)));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError || parseError instanceof Error && parseError.message === "missing request part") {
return null;
}
throw parseError;
}
}
function extractMultipartJsonRequest(body: string): string {
const match = /name="request"(?:; filename="[^"]*")?\r\n(?:Content-Type: application\/json\r\n)?\r\n(?<json>.*?)\r\n--/s.exec(body);
const json = match?.groups?.json;
if (json === undefined) {
throw new Error("missing request part");
}
return json;
}
function toMutableAudioListItem(audio: (typeof mockAudioContentListItems)[number]): AudioContentListItem {
return { ...audio };
}
function toMutableAudioDetail(audio: (typeof mockAudioContentDetails)[number]): AudioContentDetail {
return {
...audio,
creatorOtherContentList: [...audio.creatorOtherContentList],
sameThemeOtherContentList: [...audio.sameThemeOtherContentList],
commentList: [...audio.commentList],
buyerList: [...audio.buyerList],
};
}

View File

@@ -17,13 +17,14 @@ 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" };
const startOptions = { onUnhandledRequest: "error", quiet: true };
// When
await startMockWorker();
// Then
expect(setupWorkerMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything());
expect(setupWorkerMock).toHaveBeenCalled();
expect(setupWorkerMock.mock.calls[0]?.length).toBeGreaterThan(0);
expect(workerStart).toHaveBeenCalledWith(startOptions);
});
});

View File

@@ -7,5 +7,5 @@ let worker: ReturnType<typeof setupWorker> | null = null;
export async function startMockWorker(): Promise<void> {
worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl));
await worker.start({ onUnhandledRequest: "error" });
await worker.start({ onUnhandledRequest: "error", quiet: true });
}

View File

@@ -0,0 +1,146 @@
export const mockActiveCharacters = [
{
id: 101,
name: "루나",
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%2300BDF7'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='sans-serif' fill='%23062B36'%3EL%3C/text%3E%3C/svg%3E",
description: "차분한 상담형 AI 캐릭터",
gender: "여성",
age: 24,
mbti: "INFJ",
speechStyle: "다정함",
speechPattern: "존댓말",
region: "KR",
tags: ["상담", "힐링"],
createdAt: "2026-07-28 10:00:00",
updatedAt: "2026-07-28 11:00:00",
},
{
id: 102,
name: "테오",
imageUrl: null,
description: "명랑한 안내형 AI 캐릭터",
gender: "남성",
age: 28,
mbti: "ENFP",
speechStyle: "경쾌함",
speechPattern: "반말",
region: "KR",
tags: ["안내", "친근함"],
createdAt: "2026-07-27 09:00:00",
updatedAt: "2026-07-28 09:30:00",
},
] as const;
export const mockCharacterDetails = [
{
id: 101,
characterUUID: "character-uuid-101",
name: "루나",
imageUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%2300BDF7'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='sans-serif' fill='%23062B36'%3EL%3C/text%3E%3C/svg%3E",
description: "차분한 상담형 AI 캐릭터",
systemPrompt: "친절하고 안전하게 답한다.",
characterType: "Character",
age: 24,
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "다정함",
appearance: "푸른 머리와 밝은 눈",
region: "KR",
isActive: true,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: { id: 7, imageUrl: null, title: "달빛 상담소" },
},
{
id: 102,
characterUUID: "character-uuid-102",
name: "테오",
imageUrl: null,
description: "명랑한 안내형 AI 캐릭터",
systemPrompt: "짧고 명확하게 안내한다.",
characterType: "Character",
age: 28,
gender: "남성",
mbti: "ENFP",
speechPattern: "반말",
speechStyle: "경쾌함",
appearance: null,
region: "KR",
isActive: true,
tags: ["안내", "친근함"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
{
id: 202,
characterUUID: "character-uuid-202",
name: "미카",
imageUrl: null,
description: "비활성 검증용 AI 캐릭터",
systemPrompt: "읽기 전용 상태를 검증한다.",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: false,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
] as const;
export const mockOriginalWorks = [
{
id: 7,
title: "달빛 상담소",
contentType: "WEBTOON",
category: "힐링",
isAdult: false,
description: "차분한 상담 원작",
originalWork: "Moonlight Office",
originalLink: "https://example.com/moonlight",
writer: "하린",
studio: "소다스튜디오",
originalLinks: ["https://example.com/moonlight"],
tags: ["상담", "힐링"],
imageUrl: null,
},
{
id: 8,
title: "별빛 기록실",
contentType: "NOVEL",
category: "드라마",
isAdult: false,
description: "기록형 원작",
originalWork: null,
originalLink: null,
writer: "이든",
studio: "소다스튜디오",
originalLinks: [],
tags: ["기록"],
imageUrl: null,
},
] as const;

View File

@@ -0,0 +1,278 @@
import { z } from "zod";
import type { AudioContentCreateRequest, AudioContentDeactivateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import type { CharacterDetail, CharacterListItem } from "@/features/characters/model/types";
import { AudioContentMockStore } from "@/shared/mocks/audio-content-mock-store";
import { mockActiveCharacters, mockCharacterDetails, mockOriginalWorks } from "@/shared/mocks/character-fixtures";
import { CommunityPostMockStore } from "@/shared/mocks/community-post-mock-store";
import type { CommunityPostCreateMutation, CommunityPostUpdateMutation } from "@/shared/mocks/community-post-mock-store";
import { FanTalkMockStore } from "@/shared/mocks/fan-talk-mock-store";
import { SeriesMockStore } from "@/shared/mocks/series-mock-store";
import type { SeriesCreateMutation, SeriesUpdateMutation } from "@/shared/mocks/series-mock-store";
export { parseAudioContentCreateRequest, parseAudioContentDeactivateRequest, parseAudioContentUpdateRequest } from "@/shared/mocks/audio-content-mock-store";
const characterMutationRequestSchema = z.object({
name: z.string().optional(),
systemPrompt: z.string().optional(),
description: z.string().optional(),
originalWorkId: z.number().int().nullable().optional(),
isActive: z.boolean().optional(),
});
export type CharacterMutationRequest = z.infer<typeof characterMutationRequestSchema>;
export class CharacterMockStore {
readonly #audioStore = new AudioContentMockStore((characterId) => this.getCharacter(characterId));
readonly #communityPostStore = new CommunityPostMockStore((characterId) => this.getCharacter(characterId));
readonly #fanTalkStore = new FanTalkMockStore((characterId) => this.getCharacter(characterId));
readonly #seriesStore = new SeriesMockStore((characterId) => this.getCharacter(characterId));
#characterDetails: CharacterDetail[] = mockCharacterDetails.map(toMutableDetail);
#characters: CharacterListItem[] = mockActiveCharacters.map(toMutableListItem);
#nextCharacterId = 1000;
getCharacter(characterId: string) {
return this.#characterDetails.find((character) => String(character.id) === characterId) ?? null;
}
listCharacters(searchTerm: string | null, page: number, size: number) {
const normalizedSearch = searchTerm?.trim().toLowerCase() ?? "";
const filteredCharacters = normalizedSearch.length === 0
? this.#characters
: this.#characters.filter((character) => `${character.name} ${character.description} ${character.tags.join(" ")}`.toLowerCase().includes(normalizedSearch));
const start = page * size;
return {
totalCount: filteredCharacters.length,
content: filteredCharacters.slice(start, start + size),
};
}
searchOriginalWorks(searchTerm: string) {
const normalizedSearch = searchTerm.trim().toLowerCase();
return mockOriginalWorks.filter((originalWork) => `${originalWork.title} ${originalWork.contentType} ${originalWork.category}`.toLowerCase().includes(normalizedSearch)).map(toMutableOriginalWork);
}
listAudioContents(characterId: string, searchWord: string | null, page: number, size: number) {
return this.#audioStore.listAudioContents(characterId, searchWord, page, size);
}
getAudioContent(characterId: string, contentId: string) {
return this.#audioStore.getAudioContent(characterId, contentId);
}
listSeries(characterId: string, page: number, size: number) {
return this.#seriesStore.listSeries(characterId, page, size);
}
listSeriesGenres() {
return this.#seriesStore.listSeriesGenres();
}
listCommunityPosts(characterId: string, page: number, size: number) {
return this.#communityPostStore.listCommunityPosts(characterId, page, size);
}
listFanTalks(characterId: string, page: number, size: number) {
return this.#fanTalkStore.listFanTalks(characterId, page, size);
}
createFanTalkReply(characterId: string, fanTalkId: string, content: string) {
return this.#fanTalkStore.createFanTalkReply(characterId, fanTalkId, content);
}
updateFanTalkReply(characterId: string, fanTalkId: string, replyId: string, content: string) {
return this.#fanTalkStore.updateFanTalkReply(characterId, fanTalkId, replyId, content);
}
deleteFanTalk(characterId: string, fanTalkId: string) {
return this.#fanTalkStore.deleteFanTalk(characterId, fanTalkId);
}
updateCommunityPost(characterId: string, postId: string, mutation: CommunityPostUpdateMutation): boolean {
return this.#communityPostStore.updateCommunityPost(characterId, postId, mutation);
}
createCommunityPost(characterId: string, mutation: CommunityPostCreateMutation): boolean {
return this.#communityPostStore.createCommunityPost(characterId, mutation);
}
getSeriesDetail(characterId: string, seriesId: string) {
return this.#seriesStore.getSeriesDetail(characterId, seriesId);
}
listSeriesContents(characterId: string, seriesId: string, page: number, size: number) {
return this.#seriesStore.listSeriesContents(characterId, seriesId, page, size);
}
searchUnlinkedSeriesContents(characterId: string, seriesId: string, searchWord: string) {
return this.#seriesStore.searchUnlinkedSeriesContents(characterId, seriesId, searchWord);
}
addSeriesContents(characterId: string, seriesId: string, contentIdList: readonly number[]): boolean {
return this.#seriesStore.addSeriesContents(characterId, seriesId, contentIdList);
}
createSeries(characterId: string, mutation: SeriesCreateMutation): boolean {
return this.#seriesStore.createSeries(characterId, mutation);
}
updateSeries(characterId: string, seriesId: string, mutation: SeriesUpdateMutation): boolean {
return this.#seriesStore.updateSeries(characterId, seriesId, mutation);
}
removeSeriesContent(characterId: string, seriesId: string, contentId: number): boolean {
return this.#seriesStore.removeSeriesContent(characterId, seriesId, contentId);
}
updateSeriesOrder(characterId: string, ids: readonly number[]): boolean {
return this.#seriesStore.updateSeriesOrder(characterId, ids);
}
listAudioContentThemes() {
return this.#audioStore.listAudioContentThemes();
}
createAudioContent(characterId: string, request: AudioContentCreateRequest): number | null {
return this.#audioStore.createAudioContent(characterId, request);
}
updateAudioContent(characterId: string, contentId: string, request: AudioContentUpdateRequest): boolean {
return this.#audioStore.updateAudioContent(characterId, contentId, request);
}
deactivateAudioContent(characterId: string, contentId: string, request: AudioContentDeactivateRequest): boolean {
return this.#audioStore.deactivateAudioContent(characterId, contentId, request);
}
createCharacter(request: CharacterMutationRequest) {
const characterId = this.#nextCharacterId;
this.#nextCharacterId += 1;
const detail = {
id: characterId,
characterUUID: `mock-character-${characterId}`,
name: request.name ?? "새 캐릭터",
imageUrl: null,
description: request.description ?? "",
systemPrompt: request.systemPrompt ?? "",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: true,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: toOriginalWorkPreview(request.originalWorkId),
} satisfies CharacterDetail;
this.#characterDetails = [...this.#characterDetails, detail];
this.#characters = [...this.#characters, toListItem(detail)];
}
updateCharacter(characterId: string, request: CharacterMutationRequest): boolean {
const character = this.getCharacter(characterId);
if (character === null) {
return false;
}
const nextCharacter = {
...character,
name: request.name ?? character.name,
description: request.description ?? character.description,
systemPrompt: request.systemPrompt ?? character.systemPrompt,
originalWork: toOriginalWorkPreview(request.originalWorkId),
isActive: request.isActive ?? character.isActive,
} satisfies CharacterDetail;
this.#characterDetails = this.#characterDetails.map((item) => (item.id === nextCharacter.id ? nextCharacter : item));
this.#characters = nextCharacter.isActive
? this.#characters.map((item) => (item.id === nextCharacter.id ? toListItem(nextCharacter) : item))
: this.#characters.filter((item) => item.id !== nextCharacter.id);
return true;
}
}
function toOriginalWorkPreview(originalWorkId: number | null | undefined): CharacterDetail["originalWork"] {
if (originalWorkId === undefined || originalWorkId === null) {
return null;
}
const originalWork = mockOriginalWorks.find((item) => item.id === originalWorkId);
return originalWork === undefined ? null : { id: originalWork.id, imageUrl: originalWork.imageUrl, title: originalWork.title };
}
function toMutableOriginalWork(originalWork: (typeof mockOriginalWorks)[number]) {
return {
...originalWork,
originalLinks: [...originalWork.originalLinks],
tags: [...originalWork.tags],
};
}
export async function parseCharacterMutationRequest(request: Request): Promise<CharacterMutationRequest | null> {
try {
const body = await request.text();
const match = /name="request"(?:; filename="[^"]*")?\r\nContent-Type: application\/json\r\n\r\n(?<json>.*?)\r\n--/s.exec(body);
const json = match?.groups?.json;
if (json === undefined) {
return null;
}
return characterMutationRequestSchema.parse(JSON.parse(json));
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
function toMutableDetail(character: (typeof mockCharacterDetails)[number]): CharacterDetail {
return {
...character,
tags: [...character.tags],
hobbies: [...character.hobbies],
values: [...character.values],
goals: [...character.goals],
relationships: [...character.relationships],
personalities: [...character.personalities],
backgrounds: [...character.backgrounds],
memories: [...character.memories],
};
}
function toMutableListItem(character: (typeof mockActiveCharacters)[number]): CharacterListItem {
return {
...character,
tags: [...character.tags],
};
}
function toListItem(character: CharacterDetail): CharacterListItem {
return {
id: character.id,
name: character.name,
imageUrl: character.imageUrl,
description: character.description,
gender: character.gender,
age: character.age,
mbti: character.mbti,
speechStyle: character.speechStyle,
speechPattern: character.speechPattern,
region: character.region,
tags: character.tags,
createdAt: null,
updatedAt: null,
};
}

View File

@@ -0,0 +1,134 @@
import type { CommentRecord } from "@/features/comments/model/types";
import { previewCommunityPostImageUrl } from "@/shared/mocks/community-post-fixtures";
const lunaProfileUrl = "https://cdn.example.com/mock/luna.png";
export type MockComment = CommentRecord & {
readonly characterId: string;
readonly parentId: number | null;
readonly targetId: string;
readonly targetKind: "audio" | "community";
};
export const mockComments = [
{
id: 1101,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: null,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "오디오 팬 루트 댓글",
isSecret: false,
date: "2026-07-29T00:30:00Z",
replyCount: 2,
languageCode: "ko",
donationCan: 5,
},
{
id: 1102,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: null,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "오디오 AI 루트 댓글",
isSecret: false,
date: "2026-07-29T01:00:00Z",
replyCount: 0,
languageCode: null,
donationCan: 0,
},
{
id: 1201,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: 1101,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "오디오 팬 답글",
isSecret: false,
date: "2026-07-29T01:10:00Z",
replyCount: 0,
languageCode: "ko",
donationCan: 1,
},
{
id: 1202,
characterId: "101",
targetKind: "audio",
targetId: "9001",
parentId: 1101,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "오디오 AI 답글",
isSecret: false,
date: "2026-07-29T01:20:00Z",
replyCount: 0,
languageCode: null,
donationCan: 0,
},
{
id: 2101,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: null,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "커뮤니티 팬 루트 댓글",
isSecret: false,
date: "2026-07-29T02:00:00Z",
replyCount: 2,
},
{
id: 2102,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: null,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "커뮤니티 AI 루트 댓글",
isSecret: false,
date: "2026-07-29T02:10:00Z",
replyCount: 0,
},
{
id: 2201,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: 2101,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "커뮤니티 팬 답글",
isSecret: false,
date: "2026-07-29T02:20:00Z",
replyCount: 0,
},
{
id: 2202,
characterId: "101",
targetKind: "community",
targetId: "7001",
parentId: 2101,
writerId: 101,
nickname: "루나",
profileUrl: lunaProfileUrl,
comment: "커뮤니티 AI 답글",
isSecret: false,
date: "2026-07-29T02:30:00Z",
replyCount: 0,
},
] as const satisfies readonly MockComment[];

View File

@@ -0,0 +1,188 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import type { AudioCommentCreateRequest, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
import type { CommentCreateMutation, CommentTargetRef } from "@/shared/mocks/comment-mock-store";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type CommentStore = {
readonly createComment: (target: CommentTargetRef, request: CommentCreateMutation) => boolean;
readonly deleteComment: (target: CommentTargetRef, commentId: number) => boolean;
readonly listReplies: (target: CommentTargetRef, commentId: number, page: number, size: number) => unknown | null;
readonly listRootComments: (target: CommentTargetRef, page: number, size: number) => unknown | null;
readonly updateComment: (target: CommentTargetRef, commentId: number, request: CommentUpdateRequest) => boolean;
};
type CommentMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseAudioCreateRequest: (request: Request) => Promise<AudioCommentCreateRequest | null>;
readonly parseCommunityCreateRequest: (request: Request) => Promise<CommunityCommentCreateRequest | null>;
readonly parseUpdateRequest: (request: Request) => Promise<CommentUpdateRequest | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
function parseCommentId(value: string | readonly string[] | undefined): number | null {
if (typeof value !== "string") {
return null;
}
const commentId = Number(value);
return Number.isInteger(commentId) ? commentId : null;
}
function audioTarget(params: { readonly characterId?: string | readonly string[]; readonly contentId?: string | readonly string[] }): CommentTargetRef | null {
if (typeof params.characterId !== "string" || typeof params.contentId !== "string") {
return null;
}
return { kind: "audio", characterId: params.characterId, contentId: params.contentId };
}
function communityTarget(params: { readonly characterId?: string | readonly string[]; readonly postId?: string | readonly string[] }): CommentTargetRef | null {
if (typeof params.characterId !== "string" || typeof params.postId !== "string") {
return null;
}
return { kind: "community", characterId: params.characterId, postId: params.postId };
}
function listResponse(store: CommentStore, target: CommentTargetRef | null, request: Request, commentId?: number): Response {
const query = parseListQuery(request);
if (target === null || query === null) {
return HttpResponse.json(error("잘못된 요청입니다."), { status: 400 });
}
const comments = commentId === undefined
? store.listRootComments(target, query.page, query.size)
: store.listReplies(target, commentId, query.page, query.size);
return comments === null
? HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 })
: HttpResponse.json(ok(comments));
}
export function createCommentMockHandlers(store: CommentStore, accessResponse: AccessResponse, options: CommentMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
return deniedResponse ?? listResponse(store, audioTarget(params), request);
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId/replies"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
return commentId === null ? HttpResponse.json(error(options.invalidRequestMessage), { status: 400 }) : listResponse(store, audioTarget(params), request, commentId);
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = audioTarget(params);
const mutation = await options.parseAudioCreateRequest(request);
if (target === null || mutation === null || !store.createComment(target, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = audioTarget(params);
const mutation = await options.parseUpdateRequest(request);
if (target === null || commentId === null || mutation === null || !store.updateComment(target, commentId, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/audio-contents/:contentId/comments/:commentId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
const target = audioTarget(params);
if (deniedResponse !== null) {
return deniedResponse;
}
if (target === null || commentId === null || !store.deleteComment(target, commentId)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
return deniedResponse ?? listResponse(store, communityTarget(params), request);
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId/replies"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
return commentId === null ? HttpResponse.json(error(options.invalidRequestMessage), { status: 400 }) : listResponse(store, communityTarget(params), request, commentId);
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = communityTarget(params);
const mutation = await options.parseCommunityCreateRequest(request);
if (target === null || mutation === null || !store.createComment(target, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
if (deniedResponse !== null) {
return deniedResponse;
}
const target = communityTarget(params);
const mutation = await options.parseUpdateRequest(request);
if (target === null || commentId === null || mutation === null || !store.updateComment(target, commentId, mutation)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId/comments/:commentId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
const commentId = parseCommentId(params.commentId);
const target = communityTarget(params);
if (deniedResponse !== null) {
return deniedResponse;
}
if (target === null || commentId === null || !store.deleteComment(target, commentId)) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,170 @@
import { z } from "zod";
import { audioCommentCreateRequestSchema, commentUpdateRequestSchema, communityCommentCreateRequestSchema } from "@/features/comments/model/types";
import type { AudioCommentCreateRequest, CommentPage, CommentRecord, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
import type { CharacterDetail } from "@/features/characters/model/types";
import { mockComments } from "@/shared/mocks/comment-fixtures";
import type { MockComment } from "@/shared/mocks/comment-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export type CommentTargetRef =
| { readonly characterId: string; readonly contentId: string; readonly kind: "audio" }
| { readonly characterId: string; readonly kind: "community"; readonly postId: string };
export type CommentCreateMutation = AudioCommentCreateRequest | CommunityCommentCreateRequest;
export class CommentMockStore {
#comments: MockComment[] = mockComments.map(toMutableComment);
#nextCommentId = 3000;
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listRootComments(target: CommentTargetRef, page: number, size: number): CommentPage | null {
return this.#listComments(target, null, page, size);
}
listReplies(target: CommentTargetRef, commentId: number, page: number, size: number): CommentPage | null {
return this.#listComments(target, commentId, page, size);
}
createComment(target: CommentTargetRef, request: CommentCreateMutation): boolean {
const character = this.#getCharacter(target.characterId);
const parentId = request.parentId ?? null;
if (character === null || (parentId !== null && !this.#isRootParent(this.#findComment(parentId), target))) {
return false;
}
this.#nextCommentId += 1;
const baseComment = {
id: this.#nextCommentId,
characterId: target.characterId,
targetKind: target.kind,
targetId: getTargetId(target),
parentId,
writerId: character.id,
nickname: character.name,
profileUrl: character.imageUrl ?? "https://cdn.example.com/mock/luna.png",
comment: request.comment,
isSecret: request.isSecret,
date: "2026-07-29T03:00:00Z",
replyCount: 0,
} satisfies MockComment;
const comment = target.kind === "audio"
? { ...baseComment, languageCode: "languageCode" in request ? request.languageCode ?? null : null, donationCan: 0 } satisfies MockComment
: baseComment;
this.#comments = [comment, ...this.#comments];
this.#refreshReplyCounts();
return true;
}
updateComment(target: CommentTargetRef, commentId: number, request: CommentUpdateRequest): boolean {
const character = this.#getCharacter(target.characterId);
if (character === null || !this.#isCreatorOwned(this.#findComment(commentId), target, character.id)) {
return false;
}
this.#comments = this.#comments.map((comment) => this.#matchesTarget(comment, target) && comment.id === commentId ? { ...comment, comment: request.comment } : comment);
return true;
}
deleteComment(target: CommentTargetRef, commentId: number): boolean {
if (!this.#matchesTarget(this.#findComment(commentId), target)) {
return false;
}
this.#comments = this.#comments.filter((comment) => !this.#matchesTarget(comment, target) || comment.id !== commentId);
this.#refreshReplyCounts();
return true;
}
#listComments(target: CommentTargetRef, parentId: number | null, page: number, size: number): CommentPage | null {
if (this.#getCharacter(target.characterId) === null) {
return null;
}
const filtered = this.#comments.filter((comment) => this.#matchesTarget(comment, target) && comment.parentId === parentId);
const start = page * size;
return { totalCount: filtered.length, items: filtered.slice(start, start + size).map(toCommentRecord) };
}
#findComment(commentId: number): MockComment | undefined {
return this.#comments.find((comment) => comment.id === commentId);
}
#matchesTarget(comment: MockComment | undefined, target: CommentTargetRef): boolean {
return comment !== undefined && comment.characterId === target.characterId && comment.targetKind === target.kind && comment.targetId === getTargetId(target);
}
#isRootParent(comment: MockComment | undefined, target: CommentTargetRef): boolean {
return comment !== undefined && this.#matchesTarget(comment, target) && comment.parentId === null;
}
#isCreatorOwned(comment: MockComment | undefined, target: CommentTargetRef, creatorId: number): boolean {
return comment !== undefined && this.#matchesTarget(comment, target) && comment.writerId === creatorId;
}
#refreshReplyCounts(): void {
this.#comments = this.#comments.map((comment) => comment.parentId === null
? { ...comment, replyCount: this.#comments.filter((reply) => reply.parentId === comment.id).length }
: comment);
}
}
export async function parseAudioCommentCreateRequest(request: Request): Promise<AudioCommentCreateRequest | null> {
return parseJsonRequest(request, audioCommentCreateRequestSchema);
}
export async function parseCommunityCommentCreateRequest(request: Request): Promise<CommunityCommentCreateRequest | null> {
return parseJsonRequest(request, communityCommentCreateRequestSchema);
}
export async function parseCommentUpdateRequest(request: Request): Promise<CommentUpdateRequest | null> {
return parseJsonRequest(request, commentUpdateRequestSchema);
}
async function parseJsonRequest<Data>(request: Request, schema: z.ZodType<Data>): Promise<Data | null> {
try {
return schema.parse(await request.json());
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
function getTargetId(target: CommentTargetRef): string {
switch (target.kind) {
case "audio":
return target.contentId;
case "community":
return target.postId;
}
}
function toCommentRecord(comment: MockComment): CommentRecord {
const record = {
id: comment.id,
writerId: comment.writerId,
nickname: comment.nickname,
profileUrl: comment.profileUrl,
comment: comment.comment,
isSecret: comment.isSecret,
date: comment.date,
replyCount: comment.replyCount,
languageCode: comment.languageCode,
donationCan: comment.donationCan,
} satisfies CommentRecord;
return record;
}
function toMutableComment(comment: (typeof mockComments)[number]): MockComment {
return { ...comment };
}

View File

@@ -0,0 +1,77 @@
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
import { previewAudioUrl } from "@/shared/mocks/audio-content-fixtures";
export const previewCommunityPostImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='20' fill='%23D9F6FF'/%3E%3Cpath d='M34 40h60v10H34zM34 62h42v10H34zM34 84h54v10H34z' fill='%23062B36'/%3E%3C/svg%3E";
export const previewCommunityPostAudioUrl = previewAudioUrl;
export const mockCommunityPostListItems = [
{
postId: 7001,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: previewCommunityPostImageUrl,
imageUrl: null,
audioUrl: null,
content: "오늘의 상담 기록입니다.",
price: 0,
date: "2026-07-28 10:00:00",
dateUtc: "2026-07-28T01:00:00Z",
isCommentAvailable: true,
isAdult: false,
isFixed: true,
isLike: false,
existOrdered: false,
likeCount: 3,
commentCount: 1,
firstComment: {
id: 8001,
writerId: 301,
nickname: "팬",
profileUrl: previewCommunityPostImageUrl,
comment: "좋아요",
isSecret: false,
date: "2026-07-28 11:00:00",
replyCount: 0,
},
},
{
postId: 7002,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: previewCommunityPostImageUrl,
imageUrl: previewCommunityPostImageUrl,
audioUrl: null,
content: "이미지로 남긴 공지입니다.",
price: 0,
date: "2026-07-27 09:00:00",
dateUtc: "2026-07-27T00:00:00Z",
isCommentAvailable: false,
isAdult: false,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 1,
commentCount: 0,
firstComment: null,
},
{
postId: 7003,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: previewCommunityPostImageUrl,
imageUrl: null,
audioUrl: previewCommunityPostAudioUrl,
content: "오디오가 포함된 커뮤니티 게시글입니다.",
price: 100,
date: "2026-07-26 08:00:00",
dateUtc: "2026-07-25T23:00:00Z",
isCommentAvailable: true,
isAdult: true,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 5,
commentCount: 0,
firstComment: null,
},
] as const satisfies readonly CommunityPostListItem[];

View File

@@ -0,0 +1,91 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import type { CommunityPostCreateMutation, CommunityPostUpdateMutation } from "@/shared/mocks/community-post-mock-store";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type CommunityPostStore = {
readonly createCommunityPost: (characterId: string, mutation: CommunityPostCreateMutation) => boolean;
readonly listCommunityPosts: (characterId: string, page: number, size: number) => unknown | null;
readonly updateCommunityPost: (characterId: string, postId: string, mutation: CommunityPostUpdateMutation) => boolean;
};
type CommunityPostMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseCreateRequest: (request: Request) => Promise<CommunityPostCreateMutation | null>;
readonly parseUpdateRequest: (request: Request) => Promise<CommunityPostUpdateMutation | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
export function createCommunityPostMockHandlers(store: CommunityPostStore, accessResponse: AccessResponse, options: CommunityPostMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const posts = store.listCommunityPosts(characterId, query.page, query.size);
if (posts === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(posts));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts/:postId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await options.parseUpdateRequest(request);
if (mutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const postId = typeof params.postId === "string" ? params.postId : "";
if (!store.updateCommunityPost(characterId, postId, mutation)) {
return HttpResponse.json(error("커뮤니티 게시글을 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/community-posts"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await options.parseCreateRequest(request);
if (mutation === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.createCommunityPost(characterId, mutation)) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,256 @@
import { z } from "zod";
import type { CharacterDetail } from "@/features/characters/model/types";
import { communityPostCreateRequestSchema, communityPostUpdateRequestSchema } from "@/features/community-posts/model/types";
import type { CommunityPostCreateRequest, CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
import { mockCommunityPostListItems, previewCommunityPostAudioUrl, previewCommunityPostImageUrl } from "@/shared/mocks/community-post-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export type CommunityPostCreateMutation = {
readonly hasAudio: boolean;
readonly hasImage: boolean;
readonly request: CommunityPostCreateRequest;
};
export type CommunityPostUpdateMutation = {
readonly hasImage: boolean;
readonly request: CommunityPostUpdateRequest;
};
export class CommunityPostMockStore {
#posts: CommunityPostListItem[] = mockCommunityPostListItems.map(toMutableCommunityPost);
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listCommunityPosts(characterId: string, page: number, size: number) {
const character = this.#getCharacter(characterId);
if (character === null) {
return null;
}
const start = page * size;
const posts = this.#posts.filter((post) => post.creatorId === character.id);
return { totalCount: posts.length, page, size, hasNext: start + size < posts.length, items: posts.slice(start, start + size) };
}
updateCommunityPost(characterId: string, postId: string, mutation: CommunityPostUpdateMutation): boolean {
const character = this.#getCharacter(characterId);
if (character === null || !this.#posts.some((post) => String(post.postId) === postId && post.creatorId === character.id)) {
return false;
}
const request = mutation.request;
if (request.isActive === false) {
this.#posts = this.#posts.filter((post) => String(post.postId) !== postId || post.creatorId !== character.id);
return true;
}
this.#posts = this.#posts.map((post) => String(post.postId) === postId && post.creatorId === character.id ? {
...post,
content: request.content ?? post.content,
imageUrl: mutation.hasImage ? previewCommunityPostImageUrl : post.imageUrl,
isAdult: request.isAdult ?? post.isAdult,
isCommentAvailable: request.isCommentAvailable ?? post.isCommentAvailable,
isFixed: request.isFixed ?? post.isFixed,
} : post);
return true;
}
createCommunityPost(characterId: string, mutation: CommunityPostCreateMutation): boolean {
const character = this.#getCharacter(characterId);
if (character === null) {
return false;
}
const nextPostId = Math.max(0, ...this.#posts.map((post) => post.postId)) + 1;
this.#posts = [
{
postId: nextPostId,
creatorId: character.id,
creatorNickname: character.name,
creatorProfileUrl: character.imageUrl ?? previewCommunityPostImageUrl,
imageUrl: mutation.hasImage ? previewCommunityPostImageUrl : null,
audioUrl: mutation.hasAudio ? previewCommunityPostAudioUrl : null,
content: mutation.request.content,
price: mutation.request.price ?? 0,
date: "2026-07-28 12:00:00",
dateUtc: "2026-07-28T03:00:00Z",
isCommentAvailable: mutation.request.isCommentAvailable,
isAdult: mutation.request.isAdult,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 0,
commentCount: 0,
firstComment: null,
},
...this.#posts,
];
return true;
}
}
export async function parseCommunityPostCreateRequest(request: Request): Promise<CommunityPostCreateMutation | null> {
try {
const body = await parseMultipart(request, ["audioFile", "postImage"]);
if (body === null || body.requestParts.length !== 1 || body.audioFileCount > 1 || body.postImageCount > 1) {
return null;
}
const requestPart = body.requestParts[0];
if (requestPart === undefined) {
return null;
}
const mutation = communityPostCreateRequestSchema.parse(JSON.parse(await readTextPart(requestPart)));
return { hasAudio: body.audioFileCount === 1, hasImage: body.postImageCount === 1, request: mutation };
} catch (parseError) {
if (parseError instanceof Error || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
export async function parseCommunityPostUpdateRequest(request: Request): Promise<CommunityPostUpdateMutation | null> {
try {
const body = await parseMultipart(request, ["postImage"]);
if (body === null || body.requestParts.length !== 1 || body.postImageCount > 1) {
return null;
}
const requestPart = body.requestParts[0];
if (requestPart === undefined) {
return null;
}
const mutation = communityPostUpdateRequestSchema.parse(JSON.parse(await readTextPart(requestPart)));
return { hasImage: body.postImageCount === 1, request: mutation };
} catch (parseError) {
if (parseError instanceof Error || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
type MultipartParts = {
readonly audioFileCount: number;
readonly postImageCount: number;
readonly requestParts: readonly MultipartRequestPart[];
};
type MultipartRequestPart = Blob | string;
async function parseMultipart(request: Request, allowedUploads: readonly string[]): Promise<MultipartParts | null> {
const contentType = request.headers.get("content-type");
const boundary = extractMultipartBoundary(contentType);
if (boundary === null) {
return null;
}
const clonedRequest = request.clone();
const formData = await request.formData().catch(() => null);
if (formData === null) {
return parseRawMultipart(await clonedRequest.text(), allowedUploads, boundary);
}
let audioFileCount = 0;
let postImageCount = 0;
const requestParts: MultipartRequestPart[] = [];
for (const [name, value] of formData.entries()) {
if (name !== "request" && !allowedUploads.includes(name)) {
return null;
}
switch (name) {
case "audioFile":
if (!(value instanceof File)) {
return null;
}
audioFileCount += 1;
break;
case "postImage":
if (!(value instanceof File)) {
return null;
}
postImageCount += 1;
break;
case "request":
requestParts.push(value);
break;
default:
return null;
}
}
return { audioFileCount, postImageCount, requestParts };
}
function extractMultipartBoundary(contentType: string | null): string | null {
if (!contentType?.toLowerCase().startsWith("multipart/form-data")) {
return null;
}
const boundary = contentType.match(/boundary=("[^"]+"|[^;]+)/)?.[1]?.replace(/^"|"$/g, "").trim();
return boundary === undefined || boundary.length === 0 ? null : boundary;
}
function parseRawMultipart(body: string, allowedUploads: readonly string[], boundary: string): MultipartParts | null {
if (!body.startsWith(`--${boundary}\r\n`) || (!body.endsWith(`--${boundary}--\r\n`) && !body.endsWith(`--${boundary}--`))) {
return null;
}
const boundaryLines = body.match(/^--[^\r\n]+$/gm) ?? [];
if (boundaryLines.length === 0 || boundaryLines.some((line) => line !== `--${boundary}` && line !== `--${boundary}--`)) {
return null;
}
const parts = [...body.matchAll(/Content-Disposition: form-data; name="([^"]+)"(; filename="[^"]*")?\r\n(?:Content-Type: [^\r\n]+\r\n)?\r\n([\s\S]*?)(?=\r\n--)/g)];
let audioFileCount = 0;
let postImageCount = 0;
const requestParts: string[] = [];
for (const part of parts) {
const name = part[1];
const filename = part[2];
const value = part[3];
if (name === undefined || value === undefined) {
return null;
}
if (name !== "request" && !allowedUploads.includes(name)) {
return null;
}
switch (name) {
case "audioFile":
if (filename === undefined) {
return null;
}
audioFileCount += 1;
break;
case "postImage":
if (filename === undefined) {
return null;
}
postImageCount += 1;
break;
case "request":
requestParts.push(value);
break;
default:
return null;
}
}
return { audioFileCount, postImageCount, requestParts };
}
function readTextPart(part: MultipartRequestPart): Promise<string> | string {
return typeof part === "string" ? part : part.text();
}
function toMutableCommunityPost(post: (typeof mockCommunityPostListItems)[number]): CommunityPostListItem {
return {
...post,
firstComment: post.firstComment === null ? null : { ...post.firstComment },
};
}

View File

@@ -0,0 +1,31 @@
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
export const mockFanTalkListItems = [
{
fanTalkId: 7001,
writerId: 4001,
writerNickname: "달팬",
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
content: "첫 번째 응원입니다.",
createdAtUtc: "2026-07-28T01:00:00Z",
creatorReplies: [],
},
{
fanTalkId: 7002,
writerId: 4002,
writerNickname: "별팬",
writerProfileImageUrl: "https://cdn.example.com/fans/star.png",
content: "두 번째로 온 응원입니다.",
createdAtUtc: "2026-07-28T02:00:00Z",
creatorReplies: [
{
fanTalkId: 7102,
writerId: 101,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "이미 답변했습니다.",
createdAtUtc: "2026-07-28T03:00:00Z",
},
],
},
] as const satisfies readonly FanTalkListItem[];

View File

@@ -0,0 +1,127 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { error, ok } from "@/shared/mocks/contract";
type AccessResponse = (request: Request) => Response | null;
type FanTalkStore = {
readonly createFanTalkReply: (characterId: string, fanTalkId: string, content: string) => unknown | null;
readonly deleteFanTalk: (characterId: string, fanTalkId: string) => boolean;
readonly listFanTalks: (characterId: string, page: number, size: number) => unknown | null;
readonly updateFanTalkReply: (characterId: string, fanTalkId: string, replyId: string, content: string) => unknown | null;
};
type FanTalkMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
};
const fanTalkReplyCreateRequestSchema = z.strictObject({ content: z.string() });
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
async function parseReplyRequest(request: Request): Promise<string | null> {
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
return null;
}
try {
return fanTalkReplyCreateRequestSchema.parse(await request.json()).content;
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
export function createFanTalkMockHandlers(store: FanTalkStore, accessResponse: AccessResponse, options: FanTalkMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalks = store.listFanTalks(characterId, query.page, query.size);
if (fanTalks === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(fanTalks));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId/replies"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const content = await parseReplyRequest(request);
if (content === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
const reply = store.createFanTalkReply(characterId, fanTalkId, content);
if (reply === null) {
return HttpResponse.json(error("FanTalk 답변을 생성할 수 없습니다."), { status: 409 });
}
return HttpResponse.json(ok(reply));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId/replies/:replyId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const content = await parseReplyRequest(request);
if (content === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
const replyId = typeof params.replyId === "string" ? params.replyId : "";
const reply = store.updateFanTalkReply(characterId, fanTalkId, replyId, content);
if (reply === null) {
return HttpResponse.json(error("FanTalk 답변을 수정할 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(reply));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/fan-talks/:fanTalkId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
if ((await request.text()) !== "") {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const fanTalkId = typeof params.fanTalkId === "string" ? params.fanTalkId : "";
if (!store.deleteFanTalk(characterId, fanTalkId)) {
return HttpResponse.json(error("FanTalk을 삭제할 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,95 @@
import type { CharacterDetail } from "@/features/characters/model/types";
import type { FanTalkCreatorReply, FanTalkListItem } from "@/features/fan-talks/model/types";
import type { FanTalkReplyResponse } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
import { mockFanTalkListItems } from "@/shared/mocks/fan-talk-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export class FanTalkMockStore {
#fanTalks: FanTalkListItem[] = mockFanTalkListItems.map(toMutableFanTalk);
#nextReplyId = 9001;
readonly #getCharacter: GetCharacter;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listFanTalks(characterId: string, page: number, size: number) {
if (this.#getCharacter(characterId) === null) {
return null;
}
const start = page * size;
return {
fanTalkCount: this.#fanTalks.length,
fanTalks: this.#fanTalks.slice(start, start + size),
page,
size,
hasNext: start + size < this.#fanTalks.length,
};
}
createFanTalkReply(characterId: string, fanTalkId: string, content: string): FanTalkReplyResponse | null {
const character = this.#getCharacter(characterId);
const fanTalk = this.#fanTalks.find((item) => String(item.fanTalkId) === fanTalkId);
if (character === null || fanTalk === undefined || fanTalk.creatorReplies.length > 0) {
return null;
}
const reply = {
fanTalkId: fanTalk.fanTalkId,
replyId: this.#nextReplyId,
creatorMemberId: character.id,
content,
createdAtUtc: "2026-07-28T04:00:00Z",
} satisfies FanTalkReplyResponse;
this.#nextReplyId += 1;
this.#fanTalks = this.#fanTalks.map((item) => item.fanTalkId === fanTalk.fanTalkId ? {
...item,
creatorReplies: [{ fanTalkId: reply.replyId, writerId: character.id, writerNickname: character.name, writerProfileImageUrl: character.imageUrl ?? "", content: reply.content, createdAtUtc: reply.createdAtUtc }],
} : item);
return reply;
}
updateFanTalkReply(characterId: string, fanTalkId: string, replyId: string, content: string): FanTalkListItem | null {
const character = this.#getCharacter(characterId);
const fanTalk = this.#fanTalks.find((item) => String(item.fanTalkId) === fanTalkId);
const reply = fanTalk?.creatorReplies[0];
if (character === null || fanTalk === undefined || reply === undefined || String(reply.fanTalkId) !== replyId) {
return null;
}
const updatedReply: FanTalkCreatorReply = { ...reply, content, createdAtUtc: "2026-07-28T05:00:00Z" };
this.#fanTalks = this.#fanTalks.map((item) => item.fanTalkId === fanTalk.fanTalkId ? {
...item,
creatorReplies: [updatedReply],
} : item);
return {
fanTalkId: updatedReply.fanTalkId,
writerId: updatedReply.writerId,
writerNickname: updatedReply.writerNickname,
writerProfileImageUrl: updatedReply.writerProfileImageUrl,
content,
createdAtUtc: updatedReply.createdAtUtc,
creatorReplies: [],
} satisfies FanTalkListItem;
}
deleteFanTalk(characterId: string, fanTalkId: string): boolean {
if (this.#getCharacter(characterId) === null) {
return false;
}
const beforeCount = this.#fanTalks.length;
this.#fanTalks = this.#fanTalks.filter((item) => String(item.fanTalkId) !== fanTalkId);
return this.#fanTalks.length < beforeCount;
}
}
function toMutableFanTalk(fanTalk: (typeof mockFanTalkListItems)[number]): FanTalkListItem {
return {
...fanTalk,
creatorReplies: fanTalk.creatorReplies.map((reply) => ({ ...reply })),
};
}

View File

@@ -2,7 +2,16 @@ import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { CharacterMockStore, parseAudioContentCreateRequest, parseAudioContentDeactivateRequest, parseAudioContentUpdateRequest, parseCharacterMutationRequest } from "@/shared/mocks/character-mock-store";
import { createAudioContentMockHandlers } from "@/shared/mocks/audio-content-handlers";
import { createCommentMockHandlers } from "@/shared/mocks/comment-handlers";
import { CommentMockStore, parseAudioCommentCreateRequest, parseCommentUpdateRequest, parseCommunityCommentCreateRequest } from "@/shared/mocks/comment-mock-store";
import { createCommunityPostMockHandlers } from "@/shared/mocks/community-post-handlers";
import { parseCommunityPostCreateRequest, parseCommunityPostUpdateRequest } from "@/shared/mocks/community-post-mock-store";
import { error, ok } from "@/shared/mocks/contract";
import { createFanTalkMockHandlers } from "@/shared/mocks/fan-talk-handlers";
import { createSeriesMockHandlers } from "@/shared/mocks/series-handlers";
import { parseSeriesCreateRequest, parseSeriesUpdateRequest } from "@/shared/mocks/series-mock-store";
const adminToken = "mock-admin-jwt";
const memberToken = "mock-member-jwt";
@@ -16,15 +25,8 @@ const loginRequestSchema = z.strictObject({
password: z.string().min(1),
});
const aiCharactersPreview = {
totalCount: 0,
page: 0,
size: 20,
hasNext: false,
items: [],
} as const;
class MockStore {
class MockStore extends CharacterMockStore {
readonly #commentStore = new CommentMockStore((characterId) => this.getCharacter(characterId));
readonly #revokedTokens = new Set<string>();
activate(token: string): void {
@@ -45,6 +47,26 @@ class MockStore {
return "unauthorized";
}
listRootComments(target: Parameters<CommentMockStore["listRootComments"]>[0], page: number, size: number) {
return this.#commentStore.listRootComments(target, page, size);
}
listReplies(target: Parameters<CommentMockStore["listReplies"]>[0], commentId: number, page: number, size: number) {
return this.#commentStore.listReplies(target, commentId, page, size);
}
createComment(target: Parameters<CommentMockStore["createComment"]>[0], request: Parameters<CommentMockStore["createComment"]>[1]): boolean {
return this.#commentStore.createComment(target, request);
}
updateComment(target: Parameters<CommentMockStore["updateComment"]>[0], commentId: number, request: Parameters<CommentMockStore["updateComment"]>[2]): boolean {
return this.#commentStore.updateComment(target, commentId, request);
}
deleteComment(target: Parameters<CommentMockStore["deleteComment"]>[0], commentId: number): boolean {
return this.#commentStore.deleteComment(target, commentId);
}
}
export type MockFixtureStore = MockStore;
@@ -142,11 +164,75 @@ export function createMockHandlers(
}
const url = new URL(request.url);
if (url.searchParams.get("page") !== "0" || url.searchParams.get("size") !== "20") {
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
if (!Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(aiCharactersPreview));
return HttpResponse.json(ok(store.listCharacters(url.searchParams.get("searchTerm"), page, size)));
}),
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/original-works/search"), ({ request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const searchTerm = new URL(request.url).searchParams.get("searchTerm");
if (searchTerm === null || searchTerm.trim().length === 0) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(store.searchOriginalWorks(searchTerm)));
}),
http.post(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters"), async ({ request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await parseCharacterMutationRequest(request);
if (mutation === null) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
store.createCharacter(mutation);
return HttpResponse.json(ok(null));
}),
...createAudioContentMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseAudioContentCreateRequest, parseDeactivateRequest: parseAudioContentDeactivateRequest, parseUpdateRequest: parseAudioContentUpdateRequest }),
...createCommunityPostMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseCommunityPostCreateRequest, parseUpdateRequest: parseCommunityPostUpdateRequest }),
...createCommentMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseAudioCreateRequest: parseAudioCommentCreateRequest, parseCommunityCreateRequest: parseCommunityCommentCreateRequest, parseUpdateRequest: parseCommentUpdateRequest }),
...createFanTalkMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage }),
...createSeriesMockHandlers(store, (request) => accessResponse(store, request), { apiBaseUrl, invalidRequestMessage, parseCreateRequest: parseSeriesCreateRequest, parseUpdateRequest: parseSeriesUpdateRequest }),
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/:characterId"), ({ params, request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const character = store.getCharacter(characterId);
if (character === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(character));
}),
http.put(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters/:characterId"), async ({ params, request }) => {
const deniedResponse = accessResponse(store, request);
if (deniedResponse !== null) {
return deniedResponse;
}
const mutation = await parseCharacterMutationRequest(request);
if (mutation === null) {
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.updateCharacter(characterId, mutation)) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,40 @@
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
const previewCoverImageUrl = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128' viewBox='0 0 128 128'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23D9F6FF'/%3E%3Cstop offset='1' stop-color='%2300BDF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='128' height='128' rx='20' fill='url(%23g)'/%3E%3Cpath d='M38 36h52v10H38zM38 59h52v10H38zM38 82h34v10H38z' fill='%23062B36'/%3E%3C/svg%3E";
export const mockSeriesListItems = [
{
seriesId: 5001,
title: "달빛 상담 시리즈",
introduction: "밤마다 이어지는 상담 에피소드",
coverImageUrl: previewCoverImageUrl,
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
state: "PROCEEDING",
isActive: true,
writer: "스튜디오 루나",
studio: "소다랩",
},
{
seriesId: 5002,
title: "아침 루틴 시리즈",
introduction: "하루 시작을 돕는 짧은 안내",
coverImageUrl: previewCoverImageUrl,
publishedDaysOfWeek: ["RANDOM"],
genreId: 88,
isAdult: false,
state: "SUSPEND",
isActive: true,
writer: null,
studio: "소다랩",
},
] as const satisfies readonly SeriesListItem[];
export const mockSeriesGenres = [
{ id: 77, genre: "로맨스", isAdult: false },
{ id: 88, genre: "일상", isAdult: false },
{ id: 99, genre: "성인 로맨스", isAdult: true },
] as const satisfies readonly SeriesGenreItem[];
export const mockSeriesCoverImageUrl = previewCoverImageUrl;

View File

@@ -0,0 +1,231 @@
import { http, HttpResponse } from "msw";
import type { RequestHandler } from "msw";
import { z } from "zod";
import { error, ok } from "@/shared/mocks/contract";
import type { SeriesCreateMutation, SeriesUpdateMutation } from "@/shared/mocks/series-mock-store";
type AccessResponse = (request: Request) => Response | null;
type SeriesStore = {
readonly addSeriesContents: (characterId: string, seriesId: string, contentIdList: readonly number[]) => boolean;
readonly createSeries: (characterId: string, mutation: SeriesCreateMutation) => boolean;
readonly getSeriesDetail: (characterId: string, seriesId: string) => unknown | null;
readonly listSeriesGenres: () => unknown;
readonly listSeriesContents: (characterId: string, seriesId: string, page: number, size: number) => unknown | null;
readonly listSeries: (characterId: string, page: number, size: number) => unknown | null;
readonly removeSeriesContent: (characterId: string, seriesId: string, contentId: number) => boolean;
readonly searchUnlinkedSeriesContents: (characterId: string, seriesId: string, searchWord: string) => unknown | null;
readonly updateSeries: (characterId: string, seriesId: string, mutation: SeriesUpdateMutation) => boolean;
readonly updateSeriesOrder: (characterId: string, ids: readonly number[]) => boolean;
};
type SeriesMockHandlerOptions = {
readonly apiBaseUrl: string;
readonly invalidRequestMessage: string;
readonly parseCreateRequest: (request: Request) => Promise<SeriesCreateMutation | null>;
readonly parseUpdateRequest: (request: Request) => Promise<SeriesUpdateMutation | null>;
};
function endpointUrl(apiBaseUrl: string, path: string): string {
return new URL(path, apiBaseUrl).toString();
}
function parseListQuery(request: Request): { readonly page: number; readonly size: number } | null {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "0");
const size = Number(url.searchParams.get("size") ?? "20");
const queryKeys = [...url.searchParams.keys()];
if (!queryKeys.every((key) => key === "page" || key === "size") || !Number.isInteger(page) || page < 0 || !Number.isInteger(size) || size < 1) {
return null;
}
return { page, size };
}
const seriesContentAddRequestSchema = z.strictObject({ contentIdList: z.array(z.number().int()) });
const seriesOrderUpdateRequestSchema = z.strictObject({ ids: z.array(z.number().int()) });
async function parseJsonRequest<T>(request: Request, schema: z.ZodType<T>): Promise<T | null> {
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
return null;
}
try {
return schema.parse(await request.json());
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}
export function createSeriesMockHandlers(store: SeriesStore, accessResponse: AccessResponse, options: SeriesMockHandlerOptions): readonly RequestHandler[] {
return [
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/series-genres"), ({ request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
if (new URL(request.url).search !== "") {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
return HttpResponse.json(ok(store.listSeriesGenres()));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const series = store.listSeries(characterId, query.page, query.size);
if (series === null) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(series));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await options.parseCreateRequest(request);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.createSeries(characterId, body)) {
return HttpResponse.json(error("AI 캐릭터를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const series = store.getSeriesDetail(characterId, seriesId);
if (series === null) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(series));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/orders"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await parseJsonRequest(request, seriesOrderUpdateRequestSchema);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
if (!store.updateSeriesOrder(characterId, body.ids)) {
return HttpResponse.json(error("시리즈 순서가 최신 목록과 맞지 않습니다."), { status: 409 });
}
return HttpResponse.json(ok(null));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const query = parseListQuery(request);
if (query === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const contents = store.listSeriesContents(characterId, seriesId, query.page, query.size);
if (contents === null) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(contents));
}),
http.get(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents/search"), ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const url = new URL(request.url);
const searchWord = url.searchParams.get("search_word");
if (searchWord === null || [...url.searchParams.keys()].some((key) => key !== "search_word")) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const contents = store.searchUnlinkedSeriesContents(characterId, seriesId, searchWord);
if (contents === null) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(contents));
}),
http.post(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await parseJsonRequest(request, seriesContentAddRequestSchema);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
if (!store.addSeriesContents(characterId, seriesId, body.contentIdList)) {
return HttpResponse.json(error("시리즈 또는 오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.delete(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId/contents/:contentId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
if ((await request.text()) !== "") {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
const contentId = Number(typeof params.contentId === "string" ? params.contentId : "");
if (!Number.isInteger(contentId) || !store.removeSeriesContent(characterId, seriesId, contentId)) {
return HttpResponse.json(error("시리즈 또는 오디오 콘텐츠를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
http.put(endpointUrl(options.apiBaseUrl, "/api/v2/admin/ai-characters/:characterId/series/:seriesId"), async ({ params, request }) => {
const deniedResponse = accessResponse(request);
if (deniedResponse !== null) {
return deniedResponse;
}
const body = await options.parseUpdateRequest(request);
if (body === null) {
return HttpResponse.json(error(options.invalidRequestMessage), { status: 400 });
}
const characterId = typeof params.characterId === "string" ? params.characterId : "";
const seriesId = typeof params.seriesId === "string" ? params.seriesId : "";
if (!store.updateSeries(characterId, seriesId, body)) {
return HttpResponse.json(error("시리즈를 찾을 수 없습니다."), { status: 404 });
}
return HttpResponse.json(ok(null));
}),
];
}

View File

@@ -0,0 +1,189 @@
import { z } from "zod";
import type { CharacterDetail } from "@/features/characters/model/types";
import type { SeriesContentListItem, SeriesContentSearchItem, SeriesListItem } from "@/features/series/model/types";
import type { SeriesCreateRequest, SeriesDeactivateRequest, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
import { seriesCreateRequestSchema, seriesDeactivateRequestSchema, seriesUpdateRequestSchema } from "@/features/series/schemas/series-schema";
import { mockAudioContentListItems } from "@/shared/mocks/audio-content-fixtures";
import { mockSeriesCoverImageUrl, mockSeriesGenres, mockSeriesListItems } from "@/shared/mocks/series-fixtures";
type GetCharacter = (characterId: string) => CharacterDetail | null;
export type SeriesCreateMutation = { readonly image: File | null; readonly request: SeriesCreateRequest };
export type SeriesUpdateMutation = { readonly image: File | null; readonly request: SeriesUpdateRequest | SeriesDeactivateRequest };
export class SeriesMockStore {
#seriesListItems: SeriesListItem[] = mockSeriesListItems.map(toMutableSeriesListItem);
#seriesContentIds = new Map<string, readonly number[]>([["5001", [9001]]]);
readonly #getCharacter: GetCharacter;
#nextSeriesId = 6000;
constructor(getCharacter: GetCharacter) {
this.#getCharacter = getCharacter;
}
listSeries(characterId: string, page: number, size: number) {
if (this.#getCharacter(characterId) === null) {
return null;
}
const start = page * size;
return {
totalCount: this.#seriesListItems.length,
items: this.#seriesListItems.slice(start, start + size),
};
}
listSeriesGenres() {
return mockSeriesGenres.map((genre) => ({ ...genre }));
}
getSeriesDetail(characterId: string, seriesId: string) {
if (this.#getCharacter(characterId) === null) {
return null;
}
return this.#seriesListItems.find((series) => String(series.seriesId) === seriesId) ?? null;
}
createSeries(characterId: string, mutation: SeriesCreateMutation): boolean {
if (this.#getCharacter(characterId) === null || mutation.image === null) {
return false;
}
const seriesId = this.#nextSeriesId;
this.#nextSeriesId += 1;
this.#seriesListItems = [...this.#seriesListItems, { seriesId, title: mutation.request.title, introduction: mutation.request.introduction, coverImageUrl: mockSeriesCoverImageUrl, publishedDaysOfWeek: [...mutation.request.publishedDaysOfWeek], genreId: mutation.request.genreId, isAdult: mutation.request.isAdult ?? false, state: "PROCEEDING", isActive: true, writer: mutation.request.writer ?? null, studio: mutation.request.studio ?? null }];
return true;
}
updateSeries(characterId: string, seriesId: string, mutation: SeriesUpdateMutation): boolean {
if (this.#getCharacter(characterId) === null) {
return false;
}
const current = this.getSeriesDetail(characterId, seriesId);
if (current === null) {
return false;
}
if ("isActive" in mutation.request) {
this.#seriesListItems = this.#seriesListItems.filter((item) => item.seriesId !== current.seriesId);
return true;
}
const next = { ...current, title: mutation.request.title ?? current.title, introduction: mutation.request.introduction ?? current.introduction, coverImageUrl: mutation.image === null ? current.coverImageUrl : mockSeriesCoverImageUrl, publishedDaysOfWeek: mutation.request.publishedDaysOfWeek ?? current.publishedDaysOfWeek, genreId: mutation.request.genreId ?? current.genreId, isAdult: mutation.request.isAdult ?? current.isAdult, state: mutation.request.state ?? current.state, writer: mutation.request.writer ?? current.writer, studio: mutation.request.studio ?? current.studio };
this.#seriesListItems = this.#seriesListItems.map((item) => (item.seriesId === next.seriesId ? next : item));
return true;
}
listSeriesContents(characterId: string, seriesId: string, page: number, size: number) {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return null;
}
const linkedIds = this.#seriesContentIds.get(seriesId) ?? [];
const items = linkedIds.map(toSeriesContentItem).filter((item) => item !== null);
const start = page * size;
return { totalCount: items.length, items: items.slice(start, start + size) };
}
searchUnlinkedSeriesContents(characterId: string, seriesId: string, searchWord: string) {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return null;
}
const linkedIds = new Set(this.#seriesContentIds.get(seriesId) ?? []);
const normalizedSearch = searchWord.trim().toLowerCase();
return mockAudioContentListItems
.filter((item) => !linkedIds.has(item.audioContentId))
.filter((item) => `${item.title} ${item.detail} ${item.theme} ${item.tags}`.toLowerCase().includes(normalizedSearch))
.map(toSeriesContentSearchItemFromAudio);
}
addSeriesContents(characterId: string, seriesId: string, contentIdList: readonly number[]): boolean {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return false;
}
const contentIds = new Set<number>(mockAudioContentListItems.map((item) => item.audioContentId));
if (!contentIdList.every((contentId) => contentIds.has(contentId))) {
return false;
}
const currentIds = this.#seriesContentIds.get(seriesId) ?? [];
const nextIds = [...currentIds, ...contentIdList.filter((contentId) => !currentIds.includes(contentId))];
this.#seriesContentIds.set(seriesId, nextIds);
return true;
}
removeSeriesContent(characterId: string, seriesId: string, contentId: number): boolean {
if (this.getSeriesDetail(characterId, seriesId) === null) {
return false;
}
const currentIds = this.#seriesContentIds.get(seriesId) ?? [];
this.#seriesContentIds.set(seriesId, currentIds.filter((id) => id !== contentId));
return true;
}
updateSeriesOrder(characterId: string, ids: readonly number[]): boolean {
if (this.#getCharacter(characterId) === null || ids.length !== this.#seriesListItems.length) {
return false;
}
const currentIds = new Set(this.#seriesListItems.map((series) => series.seriesId));
if (!ids.every((id) => currentIds.has(id))) {
return false;
}
this.#seriesListItems = ids.map((id) => this.#seriesListItems.find((series) => series.seriesId === id)).filter((series) => series !== undefined);
return true;
}
}
function toSeriesContentItem(contentId: number): SeriesContentListItem | null {
const audio = mockAudioContentListItems.find((item) => item.audioContentId === contentId);
return audio === undefined ? null : toSeriesContentListItemFromAudio(audio);
}
function toSeriesContentListItemFromAudio(audio: (typeof mockAudioContentListItems)[number]): SeriesContentListItem {
return { contentId: audio.audioContentId, title: audio.title, coverImage: audio.coverImageUrl, isAdult: audio.isAdult };
}
function toSeriesContentSearchItemFromAudio(audio: (typeof mockAudioContentListItems)[number]): SeriesContentSearchItem {
return { contentId: audio.audioContentId, title: audio.title, coverImage: audio.coverImageUrl };
}
function toMutableSeriesListItem(series: (typeof mockSeriesListItems)[number]): SeriesListItem {
return {
...series,
publishedDaysOfWeek: [...series.publishedDaysOfWeek],
};
}
export async function parseSeriesCreateRequest(request: Request): Promise<SeriesCreateMutation | null> {
return parseSeriesMutationRequest(request, seriesCreateRequestSchema);
}
export async function parseSeriesUpdateRequest(request: Request): Promise<SeriesUpdateMutation | null> {
return parseSeriesMutationRequest(request, z.union([seriesDeactivateRequestSchema, seriesUpdateRequestSchema]));
}
async function parseSeriesMutationRequest<MutationRequest extends object>(request: Request, schema: z.ZodType<MutationRequest>): Promise<{ readonly image: File | null; readonly request: MutationRequest } | null> {
try {
const formData = await request.formData();
const requestPart = formData.get("request");
if (!(requestPart instanceof Blob)) {
return null;
}
const image = formData.get("image");
if (image !== null && !(image instanceof File)) {
return null;
}
return { image, request: schema.parse(JSON.parse(await requestPart.text())) };
} catch (parseError) {
if (parseError instanceof SyntaxError || parseError instanceof z.ZodError) {
return null;
}
throw parseError;
}
}