feat(ai-character): 리소스 관리 기반 정비
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
177
src/shared/mocks/__tests__/character-handlers.test.ts
Normal file
177
src/shared/mocks/__tests__/character-handlers.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
126
src/shared/mocks/audio-content-fixtures.ts
Normal file
126
src/shared/mocks/audio-content-fixtures.ts
Normal 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[];
|
||||
133
src/shared/mocks/audio-content-handlers.ts
Normal file
133
src/shared/mocks/audio-content-handlers.ts
Normal 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));
|
||||
}),
|
||||
];
|
||||
}
|
||||
228
src/shared/mocks/audio-content-mock-store.ts
Normal file
228
src/shared/mocks/audio-content-mock-store.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
146
src/shared/mocks/character-fixtures.ts
Normal file
146
src/shared/mocks/character-fixtures.ts
Normal 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;
|
||||
278
src/shared/mocks/character-mock-store.ts
Normal file
278
src/shared/mocks/character-mock-store.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
134
src/shared/mocks/comment-fixtures.ts
Normal file
134
src/shared/mocks/comment-fixtures.ts
Normal 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[];
|
||||
188
src/shared/mocks/comment-handlers.ts
Normal file
188
src/shared/mocks/comment-handlers.ts
Normal 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));
|
||||
}),
|
||||
];
|
||||
}
|
||||
170
src/shared/mocks/comment-mock-store.ts
Normal file
170
src/shared/mocks/comment-mock-store.ts
Normal 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 };
|
||||
}
|
||||
77
src/shared/mocks/community-post-fixtures.ts
Normal file
77
src/shared/mocks/community-post-fixtures.ts
Normal 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[];
|
||||
91
src/shared/mocks/community-post-handlers.ts
Normal file
91
src/shared/mocks/community-post-handlers.ts
Normal 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));
|
||||
}),
|
||||
];
|
||||
}
|
||||
256
src/shared/mocks/community-post-mock-store.ts
Normal file
256
src/shared/mocks/community-post-mock-store.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
31
src/shared/mocks/fan-talk-fixtures.ts
Normal file
31
src/shared/mocks/fan-talk-fixtures.ts
Normal 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[];
|
||||
127
src/shared/mocks/fan-talk-handlers.ts
Normal file
127
src/shared/mocks/fan-talk-handlers.ts
Normal 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));
|
||||
}),
|
||||
];
|
||||
}
|
||||
95
src/shared/mocks/fan-talk-mock-store.ts
Normal file
95
src/shared/mocks/fan-talk-mock-store.ts
Normal 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 })),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
40
src/shared/mocks/series-fixtures.ts
Normal file
40
src/shared/mocks/series-fixtures.ts
Normal 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;
|
||||
231
src/shared/mocks/series-handlers.ts
Normal file
231
src/shared/mocks/series-handlers.ts
Normal 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));
|
||||
}),
|
||||
];
|
||||
}
|
||||
189
src/shared/mocks/series-mock-store.ts
Normal file
189
src/shared/mocks/series-mock-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user