feat(ai-character): 커뮤니티 생성 미디어 흐름 개선

This commit is contained in:
Yu Sung
2026-08-05 00:33:49 +09:00
parent 766a06ad0a
commit fb9b550040
22 changed files with 700 additions and 141 deletions

View File

@@ -5,7 +5,7 @@ import { routePaths } from "@/app/route-paths";
import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
@@ -21,6 +21,7 @@ import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { CanPriceField } from "@/shared/ui/can-price-field";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
@@ -80,9 +81,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function changePrice(value: string) {
const nextPrice = formatPrice(value);
setPrice(nextPrice);
if (mode === "create" && nextPrice === "0") {
setPrice(value);
if (mode === "create" && parsePrice(value) === 0) {
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
}
}
@@ -236,8 +236,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" min="0" onChange={(event) => changePrice(event.currentTarget.value)} step="1" type="number" value={price} /></label><p className="text-sm text-muted-foreground">단위: </p>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} value={createSettings} /> : null}
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} ( )</p>}
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>

View File

@@ -13,14 +13,6 @@ export function parsePrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatPrice(value: string): string {
if (value.startsWith("-")) {
return "0";
}
return value.replace(/\D/g, "");
}
export const selectionCardClassName = "flex min-h-11 cursor-pointer items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 has-[:checked]:border-input has-[:checked]:bg-accent has-[:checked]:text-accent-foreground";
const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;

View File

@@ -67,10 +67,10 @@ test("AudioContentFormPage commits tag chips with Enter and comma without submit
});
test.each([
{ value: "-1", expected: "0" },
{ value: "1.5", expected: "15" },
{ value: "100000", expected: "100000" },
])("AudioContentFormPage keeps only digits in the price input for $value", async ({ value, expected }) => {
"-1",
"1.5",
"100000",
])("AudioContentFormPage preserves the raw price input %s", async (value) => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
@@ -83,7 +83,7 @@ test.each([
fireEvent.change(priceInput, { target: { value } });
// Then
expect(priceInput).toHaveValue(Number(expected));
expect(priceInput).toHaveValue(Number(value));
});
test("AudioContentFormPage shows paid options only for a positive price", async () => {
@@ -110,7 +110,7 @@ test("AudioContentFormPage shows paid options only for a positive price", async
expect(screen.getByLabelText("포인트 사용")).toBeInTheDocument();
});
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price returns to zero", async () => {
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price is numeric zero", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
@@ -124,7 +124,7 @@ test("AudioContentFormPage resets paid settings and retains removed-control defa
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00:05" } });
// When
fireEvent.change(priceInput, { target: { value: "0" } });
fireEvent.change(priceInput, { target: { value: "00" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();

View File

@@ -26,6 +26,38 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
};
}
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s before update", async (value) => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const priceInput = await screen.findByLabelText("가격");
fireEvent.change(priceInput, { target: { value } });
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" }));
// Then
expect(priceInput).toHaveValue(Number(value));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage allows the maximum edit price", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
fireEvent.change(await screen.findByLabelText("가격"), { target: { value: "99999" } });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
});
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
// Given
const requests: CapturedRequest[] = [];

View File

@@ -161,9 +161,10 @@ test("AudioContentFormPage serializes supported create settings with removed-con
});
test.each([
{ label: "음수 기호", value: "-1", expected: "0" },
{ label: "소수점", value: "1.5", expected: "15" },
])("AudioContentFormPage removes non-digits from price input for %s", async ({ label, value, expected }) => {
"-1",
"1.5",
"100000",
])("AudioContentFormPage preserves and rejects invalid price input %s before upload", async (value) => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -171,17 +172,21 @@ test.each([
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: label } });
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "잘못된 가격" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(Number(expected));
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
@@ -237,32 +242,6 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
expect(request).not.toHaveProperty("timezone");
});
test("AudioContentFormPage blocks prices outside the CAN range before upload", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "가격 경계" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(100000);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
test("AudioContentFormPage links validation errors and focuses the first invalid control", async () => {
// Given
const requests: CapturedRequest[] = [];
@@ -288,7 +267,7 @@ test("AudioContentFormPage links validation errors and focuses the first invalid
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
expect(screen.getByLabelText("태그").getAttribute("aria-describedby")?.split(" ")).toContain("audio-content-tags-error");
expect(screen.getByLabelText("태그")).toHaveAccessibleDescription("쉼표 또는 Enter로 태그를 추가하세요. 태그를 입력하세요.");
expect(screen.getByLabelText("가격")).not.toHaveAttribute("aria-describedby");
expect(screen.getByLabelText("가격")).toHaveAccessibleDescription("단위: 캔");
expect(screen.getByLabelText("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
expect(coverInput).toHaveAttribute("aria-invalid", "true");

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import { communityPostAudioErrorMessage, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { CanPriceField } from "@/shared/ui/can-price-field";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
@@ -27,12 +28,6 @@ const errorIds = {
price: "community-post-price-error",
} as const;
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
const base = { content, isAdult, isCommentAvailable };
return price === null ? base : { ...base, price };
}
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [content, setContent] = useState("");
@@ -43,7 +38,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
const [isImagePreparing, setIsImagePreparing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [postImage, setPostImage] = useState<File | null>(null);
const [price, setPrice] = useState("");
const [price, setPrice] = useState("0");
const formRef = useRef<HTMLFormElement>(null);
const imageSelectionId = useRef(0);
const isSavingRef = useRef(false);
@@ -53,9 +48,11 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
async function selectPostImage(file: File | null) {
imageSelectionId.current += 1;
const currentSelectionId = imageSelectionId.current;
setAudioFile(null);
setErrors((current) => ({ ...current, audio: undefined }));
setPostImage(null);
setCropSource(null);
if (file === null) {
setPostImage(null);
setCropSource(null);
setIsImagePreparing(false);
setErrors((current) => ({ ...current, image: undefined }));
return;
@@ -110,13 +107,12 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
function validateForm(): CommunityPostFormErrors {
const parsedPrice = parseCommunityPostPrice(price);
const hasPriceInput = price.trim().length > 0;
return {
audio: communityPostAudioErrorMessage(audioFile),
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
price: parsedPrice === null || !Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
};
}
@@ -131,6 +127,10 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
const parsedPrice = parseCommunityPostPrice(price);
if (parsedPrice === null) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
@@ -138,7 +138,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
await createCommunityPost(apiClient, characterId, {
audioFile: audioFile ?? undefined,
postImage: postImage ?? undefined,
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
request: { content: content.trim(), isAdult, isCommentAvailable, price: parsedPrice },
});
onCreated();
} catch (error: unknown) {
@@ -154,17 +154,16 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">COMMUNITY</p>
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title"> </h2>
<p className="text-sm text-muted-foreground"> . GIF는 crop .</p>
<p className="break-keep text-sm text-muted-foreground"> . GIF는 crop .</p>
</div>
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{postImage === null ? null : <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />}
<label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
{errors.content === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.content} role="alert">{errors.content}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatCommunityPostPrice(event.currentTarget.value))} value={price} /></label>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={setPrice} value={price} />
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(event.currentTarget.checked)} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" /> </label>
<FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
<div className="flex justify-end gap-2">

View File

@@ -1,5 +1,5 @@
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
import { parseCanPriceInput } from "@/shared/validation/can-price";
export type CommunityPostFormErrors = {
readonly audio?: string;
@@ -13,10 +13,6 @@ export function parseCommunityPostPrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatCommunityPostPrice(value: string): string {
return formatCanPriceInput(value);
}
export function communityPostAudioErrorMessage(file: File | null): string | undefined {
if (file === null) {
return undefined;

View File

@@ -68,6 +68,7 @@ describe("Community post contract", () => {
// Then
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts", method: "POST", body: requests[0]?.body }]);
const body = requireFormData(requests[0]?.body);
expect([...body.keys()]).toEqual(["audioFile", "postImage", "request"]);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(postImage);
const request = await readJsonPart(body.get("request"));

View File

@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
@@ -71,6 +71,18 @@ function createCropSource(file: File): Promise<CropSourceImage> {
return Promise.resolve({ file, height: 600, previewUrl: "blob:gif", width: 800 });
}
beforeEach(() => {
class TestUrl extends URL {
static createObjectURL(blob: Blob): string {
return `blob:community-${blob.size}`;
}
static revokeObjectURL(): void {}
}
vi.stubGlobal("URL", TestUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
@@ -91,6 +103,7 @@ test("Community create route opens from the existing list workspace", async () =
// Then
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글 생성" })).toBeInTheDocument();
expect(screen.getByText("내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.")).toHaveClass("break-keep");
expect(window.location.pathname).toBe("/ai-characters/101/community-posts/new");
});
@@ -138,6 +151,8 @@ test("Community create form validates content audio policy and GIF width before
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "GIF와 오디오가 있는 게시글" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
await screen.findByLabelText("오디오 파일");
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -145,7 +160,6 @@ test("Community create form validates content audio policy and GIF width before
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [audioFile] } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "big.gif", { type: "image/gif" })] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -155,6 +169,9 @@ test("Community create form validates content audio policy and GIF width before
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
const revealedAudioInput = await screen.findByLabelText("오디오 파일");
expect(screen.queryByText("MP3, AAC, M4A 파일만 업로드하세요.")).not.toBeInTheDocument();
fireEvent.change(revealedAudioInput, { target: { files: [audioFile] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -164,7 +181,98 @@ test("Community create form validates content audio policy and GIF width before
const body = requireFormData(requests.at(-1)?.body);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(animatedGif);
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true });
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true, price: 0 });
});
test("Community create form renders final image preview first and reveals audio before content", async () => {
// Given
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createCropSource} />);
const form = await screen.findByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
if (!(form instanceof HTMLFormElement)) {
throw new Error("expected community create form");
}
const imageInput = screen.getByLabelText("게시글 이미지");
const contentInput = screen.getByLabelText("내용");
// Then
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
// When
fireEvent.change(imageInput, { target: { files: [new File(["gif"], "post.gif", { type: "image/gif" })] } });
// Then
const preview = await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" });
const audioInput = screen.getByLabelText("오디오 파일");
expect(preview).toHaveAttribute("src", "blob:community-3");
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(audioInput));
expect(Array.from(form.elements).indexOf(audioInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
});
test("Community create form clears final image and audio as soon as image replacement starts", async () => {
// Given
let resolveReplacement: (source: CropSourceImage) => void = () => undefined;
const replacementReady = new Promise<CropSourceImage>((resolve) => {
resolveReplacement = resolve;
});
const createReplacementCropSource = (file: File) => file.name === "replacement.png" ? replacementReady : createCropSource(file);
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createReplacementCropSource} />);
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
const audioInput = await screen.findByLabelText("오디오 파일");
fireEvent.change(audioInput, { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "replacement.png", { type: "image/png" })] } });
// Then
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(screen.queryByRole("img", { name: "게시글 이미지 업로드 미리보기" })).not.toBeInTheDocument();
await act(async () => {
resolveReplacement({ file: new File(["image"], "replacement.png", { type: "image/png" }), height: 500, previewUrl: "blob:replacement", width: 1000 });
await replacementReady;
});
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
});
test("Community create form hides and clears audio when the final image is removed", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
fireEvent.change(await screen.findByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
// When
fireEvent.click(screen.getByRole("button", { name: "게시글 이미지 선택 취소" }));
// Then
await waitFor(() => expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument());
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "이미지 제거 게시글" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
const body = requireFormData(requests.at(-1)?.body);
expect(body.has("audioFile")).toBe(false);
expect(body.has("postImage")).toBe(false);
});
test("Community create form defaults price to zero and rejects blank price", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
const priceInput = await screen.findByLabelText("가격");
expect(priceInput).toHaveValue(0);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "빈 가격 게시글" } });
fireEvent.change(priceInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("Community create form crops JPEG PNG with free ratio and no upscale", async () => {
@@ -177,7 +285,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "PNG crop 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
@@ -185,6 +293,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(renderCrop).toHaveBeenCalled());
expect(await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:community-7");
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -203,7 +312,7 @@ test("Community create form blocks prices outside the CAN range before submit",
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -219,7 +328,7 @@ test("Community create form links validation errors and focuses the first invali
// When
const contentInput = await screen.findByLabelText("내용");
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -228,7 +337,7 @@ test("Community create form links validation errors and focuses the first invali
expect(contentError).toHaveAttribute("id", "community-post-content-error");
expect(priceError).toHaveAttribute("id", "community-post-price-error");
expect(contentInput).toHaveAttribute("aria-describedby", "community-post-content-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "community-post-price-error");
expect(screen.getByLabelText("가격").getAttribute("aria-describedby")?.split(" ")).toContain("community-post-price-error");
expect(contentInput).toHaveAttribute("aria-invalid", "true");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(contentInput).toHaveFocus());
@@ -295,7 +404,7 @@ test("Community create form ignores stale image preparation results", async () =
expect(submittedImage.name).toBe("fresh.gif");
});
test("Community create form clears pending image preparation when selection is canceled", async () => {
test("Community create form clears image replacement when crop is canceled", async () => {
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
resolveCropSource = resolve;
@@ -309,11 +418,11 @@ test("Community create form clears pending image preparation when selection is c
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "slow.png", { type: "image/png" })] } });
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
await act(async () => {
resolveCropSource({ file: new File(["image"], "slow.png", { type: "image/png" }), height: 500, previewUrl: "blob:slow", width: 1000 });
await cropSourceReady;
});
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "생성" }));

View File

@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
import { formatCommunityPostPrice, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import { parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
@@ -27,25 +27,25 @@ function renderForm(requests: CapturedRequest[]) {
return onCreated;
}
async function submitWithPrice(value: string, requests: CapturedRequest[]) {
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
}
test.each(["-1", "1.5"])("Community price keeps invalid raw input %s and blocks submit", async (value) => {
test.each([
"-1",
"1.5",
"100000",
])("Community price preserves and rejects invalid raw input %s", async (rawValue) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
await submitWithPrice(value, requests);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
expect(screen.getByLabelText("가격")).toHaveValue(value);
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
expect(onCreated).not.toHaveBeenCalled();
});
test.each(["0", "99,999"])("Community price allows boundary input %s", async (value) => {
test.each(["0", "99999"])("Community price allows boundary input %s", async (value) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
@@ -57,9 +57,8 @@ test.each(["0", "99,999캔"])("Community price allows boundary input %s", async
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
});
test("Community price parser rejects negative and decimal raw input", () => {
test("Community price parser rejects blank, negative, and decimal raw input", () => {
expect(parseCommunityPostPrice("")).toBeNull();
expect(parseCommunityPostPrice("-1")).toBeNull();
expect(parseCommunityPostPrice("1.5")).toBeNull();
expect(formatCommunityPostPrice("-1")).toBe("-1");
expect(formatCommunityPostPrice("1.5")).toBe("1.5");
});