feat(ai-character): 커뮤니티 생성 미디어 흐름 개선
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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: "생성" }));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user