diff --git a/package.json b/package.json index a5fdd68..f8b0d80 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "eslint .", "test:timetable": "node --experimental-strip-types --test tests/timetable/*.test.ts", "test:feedback": "node --experimental-strip-types --test tests/feedback/*.test.ts", + "test:forms": "node --experimental-strip-types --test tests/forms/*.test.ts", "test:todo": "node --experimental-strip-types --test tests/todo/*.test.ts", "preview": "vite preview", "deploy": "git subtree push --prefix gh-pages origin gh-pages" diff --git a/src/components/Editor/EditorSidebar/QuickAddDialog.tsx b/src/components/Editor/EditorSidebar/QuickAddDialog.tsx index 031894d..ac3f13e 100644 --- a/src/components/Editor/EditorSidebar/QuickAddDialog.tsx +++ b/src/components/Editor/EditorSidebar/QuickAddDialog.tsx @@ -20,7 +20,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useEditorContext } from '@/hooks/useEditorContext'; import { Plus } from 'lucide-react'; import { toast } from 'sonner'; -import { validateLinkForm } from '@/utils/formValidation'; +import { + getFirstValidationMessage, + LINK_NAME_MAX_LENGTH, + linkFormSchema, +} from '@/utils/formValidation'; import { IconGrid } from '@/components/Editor/shared/IconGrid'; import type { Icon } from '@/types/api'; @@ -73,19 +77,17 @@ const QuickAddDialogContent = ({ ); const handleAdd = () => { - // Validate form using centralized validation - const validation = validateLinkForm(name, url, selectedIconId, 15); - if (!validation.valid) { - toast.error(validation.error!); + const validation = linkFormSchema.safeParse({ + name, + url, + iconId: selectedIconId, + }); + if (!validation.success) { + toast.error(getFirstValidationMessage(validation.error)); return; } - // Add link with iconId - onAdd({ - name: name.trim(), - url: url.trim(), - iconId: selectedIconId!, - }); + onAdd(validation.data); // Close dialog onOpenChange(false); @@ -103,19 +105,19 @@ const QuickAddDialogContent = ({
{/* Name Input */}
- + { const value = e.target.value; - if (value.length <= 15) { + if (value.length <= LINK_NAME_MAX_LENGTH) { setName(value); } }} autoComplete="off" - maxLength={15} + maxLength={LINK_NAME_MAX_LENGTH} />
diff --git a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx index 671a13a..33bfd06 100644 --- a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx +++ b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx @@ -12,7 +12,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Trash2, Save, ArrowRight } from 'lucide-react'; import { toast } from 'sonner'; import { GRID_CONFIG } from '@/utils/template'; -import { validateLinkForm } from '@/utils/formValidation'; +import { + getFirstValidationMessage, + LINK_NAME_MAX_LENGTH, + linkFormSchema, +} from '@/utils/formValidation'; import { IconGrid } from '@/components/Editor/shared/IconGrid'; import type { TemplateIcon, TemplateItem } from '@/types/api'; import { InputGroup } from '@/components/Editor/shared/InputGroup'; @@ -96,16 +100,21 @@ const ItemPropertiesPanelForm = ({ }, [selectedItem]); const handleSave = () => { - // Validate form using centralized validation - const validation = validateLinkForm(name, url, selectedIconId, 15); - if (!validation.valid) { - toast.error(validation.error!); + const validation = linkFormSchema.safeParse({ + name, + url, + iconId: selectedIconId, + }); + if (!validation.success) { + toast.error(getFirstValidationMessage(validation.error)); return; } + const { name: validatedName, url: validatedUrl, iconId } = validation.data; + // Find selected icon const allIcons = [...defaultIcons, ...userIcons]; - const icon = allIcons.find((i) => i.id === selectedIconId); + const icon = allIcons.find((i) => i.id === iconId); if (!icon) { toast.error('선택한 아이콘을 찾을 수 없습니다.'); return; @@ -129,8 +138,8 @@ const ItemPropertiesPanelForm = ({ payload: { id: selectedItem.templateItemId, changes: { - name: name.trim(), - siteUrl: url.trim(), + name: validatedName, + siteUrl: validatedUrl, icon: { iconId: icon.id, iconName: icon.name, @@ -180,19 +189,21 @@ const ItemPropertiesPanelForm = ({ {/* Name Input */}
- + { const value = e.target.value; - if (value.length <= 15) { + if (value.length <= LINK_NAME_MAX_LENGTH) { setName(value); } }} placeholder="예: 이캠퍼스" className="h-8" - maxLength={15} + maxLength={LINK_NAME_MAX_LENGTH} />
diff --git a/src/components/EmailVerificationDialog.tsx b/src/components/EmailVerificationDialog.tsx index 3bc2437..d06f67c 100644 --- a/src/components/EmailVerificationDialog.tsx +++ b/src/components/EmailVerificationDialog.tsx @@ -18,8 +18,9 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { sendVerificationCode, verifyEmailCode } from '@/apis/auth'; import { - validateKonkukEmail, - validateAuthCode, + authCodeSchema, + getFirstValidationMessage, + konkukEmailSchema, } from '@/utils/formValidation'; import { errorLog } from '@/utils/logger'; import { sendAuthEmailVerificationStart, sendAuthEmailVerificationSuccess } from '@/utils/analytics'; @@ -60,18 +61,18 @@ export function EmailVerificationDialog({ return; } - // Validate full email - const validation = validateKonkukEmail(kuMail); - if (!validation.valid) { - toast.error(validation.error); + const validation = konkukEmailSchema.safeParse(kuMail); + if (!validation.success) { + toast.error(getFirstValidationMessage(validation.error)); return; } setIsLoading(true); try { - const response = await sendVerificationCode({ kuMail }); + const response = await sendVerificationCode({ kuMail: validation.data }); if (response.success) { + setEmailId(validation.data.slice(0, -EMAIL_DOMAIN.length)); toast.success('인증 코드가 발송되었습니다. 이메일을 확인해주세요.'); setStep('code'); } else { @@ -94,16 +95,18 @@ export function EmailVerificationDialog({ }; const handleVerifyCode = async () => { - // Validate code - const validation = validateAuthCode(authCode); - if (!validation.valid) { - toast.error(validation.error); + const validation = authCodeSchema.safeParse(authCode); + if (!validation.success) { + toast.error(getFirstValidationMessage(validation.error)); return; } setIsLoading(true); try { - const response = await verifyEmailCode({ kuMail, authCode }); + const response = await verifyEmailCode({ + kuMail, + authCode: validation.data, + }); if (response.success) { toast.success('이메일 인증이 완료되었습니다!'); diff --git a/src/components/Labs/QRGeneratorSection.tsx b/src/components/Labs/QRGeneratorSection.tsx index c46d4f1..9457a87 100644 --- a/src/components/Labs/QRGeneratorSection.tsx +++ b/src/components/Labs/QRGeneratorSection.tsx @@ -6,6 +6,7 @@ import { Info, Download, Check, Upload, X } from "lucide-react"; import QRCode from "qrcode"; import { warnLog } from '@/utils/logger'; import { sendLabsFeatureUse } from '@/utils/analytics'; +import { qrUrlSchema } from '@/utils/formValidation'; // LinKU 로고 (public/assets/icon128.png) - 고해상도 사용 const LINKU_LOGO_URL = "/assets/icon128.png"; @@ -90,16 +91,14 @@ const QRGeneratorSection = () => { return; } - // URL 유효성 검사 - try { - new URL(inputUrl); - } catch { + const validation = qrUrlSchema.safeParse(inputUrl); + if (!validation.success) { setError("올바른 URL 형식이 아닙니다"); setQrDataUrl(""); return; } - setActiveUrl(inputUrl); + setActiveUrl(validation.data); setError(""); }, [inputUrl]); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 8b6729c..5ed6e9c 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -53,6 +53,10 @@ import { refreshTodoCount, } from "@/utils/todo/count"; import { errorLog } from '@/utils/logger'; +import { + eCampusCredentialsSchema, + getFirstValidationMessage, +} from "@/utils/formValidation"; interface SettingsDialogProps { open: boolean; @@ -100,15 +104,24 @@ const ECampusCredential = () => { // 인증 정보 저장하기 const saveCredentials = async () => { - if (!savedId || !savedPassword) { - toast.error("ID와 비밀번호를 모두 입력해주세요."); + const validation = eCampusCredentialsSchema.safeParse({ + userId: savedId, + userPw: savedPassword, + }); + if (!validation.success) { + toast.error(getFirstValidationMessage(validation.error)); return; } + const credentials = validation.data; + setIsSaving(true); try { - const loginAttempt = await loginECampusAccount(savedId, savedPassword); + const loginAttempt = await loginECampusAccount( + credentials.userId, + credentials.userPw, + ); if (loginAttempt.superseded) { toast.error("다른 계정 변경으로 저장을 완료하지 않았습니다."); return; @@ -123,7 +136,7 @@ const ECampusCredential = () => { } // 검증에 성공한 계정만 브라우저에 저장한다. - await saveECampusCredentials(savedId, savedPassword); + await saveECampusCredentials(credentials.userId, credentials.userPw); if (!isECampusAccountCurrent(loginAttempt.requestGeneration)) { return; } diff --git a/src/components/Tabs/TodoList/LoginDialog.tsx b/src/components/Tabs/TodoList/LoginDialog.tsx index 7f87492..79f1849 100644 --- a/src/components/Tabs/TodoList/LoginDialog.tsx +++ b/src/components/Tabs/TodoList/LoginDialog.tsx @@ -21,6 +21,10 @@ import { } from "@/utils/ecampus/todos"; import { errorLog } from "@/utils/logger"; import { clearECampusTodoCount } from "@/utils/todo/count"; +import { + eCampusCredentialsSchema, + getFirstValidationMessage, +} from "@/utils/formValidation"; interface LoginDialogProps { isOpen: boolean; @@ -46,18 +50,21 @@ const LoginDialog = ({ }, [isOpen]); const handleLogin = async () => { - if (!userId || !userPw) { - setError("ID와 비밀번호를 모두 입력해주세요."); + const validation = eCampusCredentialsSchema.safeParse({ userId, userPw }); + if (!validation.success) { + setError(getFirstValidationMessage(validation.error)); return; } + const credentials = validation.data; + setError(""); setIsSubmitting(true); try { const loginAttempt = await loginECampusAccount( - userId, - userPw, + credentials.userId, + credentials.userPw, ); if (loginAttempt.superseded) { @@ -77,7 +84,7 @@ const LoginDialog = ({ if (rememberLogin) { try { - await saveECampusCredentials(userId, userPw); + await saveECampusCredentials(credentials.userId, credentials.userPw); } catch (saveError) { errorLog("Failed to save credentials:", saveError); } diff --git a/src/components/Tabs/TodoList/TodoAddDialog.tsx b/src/components/Tabs/TodoList/TodoAddDialog.tsx index d6a9a11..91d6133 100644 --- a/src/components/Tabs/TodoList/TodoAddDialog.tsx +++ b/src/components/Tabs/TodoList/TodoAddDialog.tsx @@ -13,6 +13,10 @@ import { addCustomTodo } from "@/utils/todo/customTodo"; import { toast } from "sonner"; import { errorLog } from '@/utils/logger'; import { sendTodoItemCreate } from '@/utils/analytics'; +import { + getFirstValidationMessage, + todoInputSchema, +} from '@/utils/formValidation'; interface TodoAddDialogProps { open: boolean; @@ -56,34 +60,32 @@ const TodoAddDialog = ({ open, onOpenChange, onSuccess }: TodoAddDialogProps) => const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!title.trim()) { - toast.error("할 일 제목을 입력해주세요."); - return; - } - - if (!dueDate) { - toast.error("마감일을 선택해주세요."); + const validation = todoInputSchema.safeParse({ + title, + subject, + dueDate, + dueTime, + }); + if (!validation.success) { + toast.error(getFirstValidationMessage(validation.error)); return; } - if (!dueTime) { - toast.error("마감 시간을 선택해주세요."); - return; - } + const todo = validation.data; setIsSubmitting(true); try { // YYYY-MM-DD → YYYY.MM.DD 변환 - const formattedDate = dueDate.replace(/-/g, '.'); + const formattedDate = todo.dueDate.replace(/-/g, '.'); await addCustomTodo( - title.trim(), + todo.title, formattedDate, - dueTime, - subject.trim() || undefined + todo.dueTime, + todo.subject || undefined ); - sendTodoItemCreate("dialog", Boolean(dueDate)); + sendTodoItemCreate("dialog", Boolean(todo.dueDate)); toast.success("할 일이 추가되었습니다."); // 다이얼로그 닫기 (폼 초기화는 useEffect에서 처리) diff --git a/src/utils/formValidation.ts b/src/utils/formValidation.ts index 555460b..4d941ab 100644 --- a/src/utils/formValidation.ts +++ b/src/utils/formValidation.ts @@ -1,149 +1,77 @@ /** - * Form Validation Utilities - * Centralized validation logic for editor forms + * Shared input schemas. + * + * Keep user-facing validation messages close to their constraints so every + * form consumes the same rules without rebuilding conditional checks. */ - -export interface ValidationResult { - valid: boolean; - error?: string; -} - -/** - * Validate link name - * @param name - The name to validate - * @param maxLength - Maximum allowed length (default: 15) - */ -export function validateName(name: string, maxLength: number = 15): ValidationResult { - if (!name.trim()) { - return { - valid: false, - error: '링크 이름을 입력해주세요.', - }; - } - - if (name.trim().length > maxLength) { - return { - valid: false, - error: `링크 이름은 ${maxLength}자 이하로 입력해주세요.`, - }; - } - - return { valid: true }; -} - -/** - * Validate URL - * @param url - The URL string to validate - */ -export function validateUrl(url: string): ValidationResult { - if (!url.trim()) { - return { - valid: false, - error: '링크 URL을 입력해주세요.', - }; - } - - // Validate URL format - try { - new URL(url); - } catch { - return { - valid: false, - error: '올바른 URL을 입력해주세요.', - }; - } - - return { valid: true }; -} - -/** - * Validate icon selection - * @param iconId - The selected icon ID - */ -export function validateIcon(iconId: number | null): ValidationResult { - if (!iconId) { - return { - valid: false, - error: '아이콘을 선택해주세요.', - }; - } - - return { valid: true }; -} - -/** - * Validate all form fields at once - * Returns first error found, or null if all valid - */ -export function validateLinkForm( - name: string, - url: string, - iconId: number | null, - maxNameLength: number = 15 -): ValidationResult { - const nameValidation = validateName(name, maxNameLength); - if (!nameValidation.valid) return nameValidation; - - const urlValidation = validateUrl(url); - if (!urlValidation.valid) return urlValidation; - - const iconValidation = validateIcon(iconId); - if (!iconValidation.valid) return iconValidation; - - return { valid: true }; -} - -/** - * Validate Konkuk University email - * @param email - The email to validate (@konkuk.ac.kr) - */ -export function validateKonkukEmail(email: string): ValidationResult { - if (!email.trim()) { - return { - valid: false, - error: '이메일을 입력해주세요.', - }; - } - - // Check email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return { - valid: false, - error: '올바른 이메일 형식을 입력해주세요.', - }; - } - - // Check if it's a Konkuk email - if (!email.toLowerCase().endsWith('@konkuk.ac.kr')) { - return { - valid: false, - error: '건국대학교 이메일(@konkuk.ac.kr)만 사용 가능합니다.', - }; - } - - return { valid: true }; -} - -/** - * Validate verification code - * @param code - The 6-digit verification code - */ -export function validateAuthCode(code: string): ValidationResult { - if (!code.trim()) { - return { - valid: false, - error: '인증 코드를 입력해주세요.', - }; - } - - // Check if it's 6 digits - if (!/^\d{6}$/.test(code)) { - return { - valid: false, - error: '6자리 숫자를 입력해주세요.', - }; - } - - return { valid: true }; +import * as z from "zod/mini"; + +export const LINK_NAME_MAX_LENGTH = 15; + +const trimmedString = () => z.string().check(z.trim()); + +export const linkNameSchema = trimmedString().check( + z.minLength(1, "링크 이름을 입력해주세요."), + z.maxLength( + LINK_NAME_MAX_LENGTH, + `링크 이름은 ${LINK_NAME_MAX_LENGTH}자 이하로 입력해주세요.`, + ), +); + +export const linkUrlSchema = z.pipe( + trimmedString().check(z.minLength(1, "링크 URL을 입력해주세요.")), + z.url("올바른 URL을 입력해주세요."), +); + +const iconIdSchema = z.number("아이콘을 선택해주세요.").check( + z.refine((iconId) => iconId > 0, "아이콘을 선택해주세요."), +); + +export const linkFormSchema = z.object({ + name: linkNameSchema, + url: linkUrlSchema, + iconId: iconIdSchema, +}); + +const emailSchema = trimmedString().check( + z.minLength(1, "이메일을 입력해주세요."), + z.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "올바른 이메일 형식을 입력해주세요."), +); + +export const konkukEmailSchema = z.pipe( + emailSchema, + trimmedString().check( + z.refine( + (email) => email.toLowerCase().endsWith("@konkuk.ac.kr"), + "건국대학교 이메일(@konkuk.ac.kr)만 사용 가능합니다.", + ), + ), +); + +export const authCodeSchema = trimmedString().check( + z.minLength(1, "인증 코드를 입력해주세요."), + z.regex(/^\d{6}$/, "6자리 숫자를 입력해주세요."), +); + +export const todoInputSchema = z.object({ + title: trimmedString().check(z.minLength(1, "할 일 제목을 입력해주세요.")), + subject: trimmedString(), + dueDate: z.string().check(z.minLength(1, "마감일을 선택해주세요.")), + dueTime: z.string().check(z.minLength(1, "마감 시간을 선택해주세요.")), +}); + +export const eCampusCredentialsSchema = z.object({ + userId: z.string().check(z.minLength(1, "ID와 비밀번호를 모두 입력해주세요.")), + userPw: z.string().check(z.minLength(1, "ID와 비밀번호를 모두 입력해주세요.")), +}); + +export const qrUrlSchema = z.pipe( + trimmedString().check(z.minLength(1, "올바른 URL 형식이 아닙니다")), + z.url("올바른 URL 형식이 아닙니다"), +); + +export function getFirstValidationMessage(error: { + issues: Array<{ message: string }>; +}) { + return error.issues[0]?.message ?? "입력값을 확인해주세요."; } diff --git a/tests/forms/formValidation.test.ts b/tests/forms/formValidation.test.ts new file mode 100644 index 0000000..16de9ba --- /dev/null +++ b/tests/forms/formValidation.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + authCodeSchema, + eCampusCredentialsSchema, + konkukEmailSchema, + LINK_NAME_MAX_LENGTH, + linkFormSchema, + qrUrlSchema, + todoInputSchema, +} from "../../src/utils/formValidation.ts"; + +function expectError( + result: ReturnType, + message: string, +) { + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.error.issues[0]?.message, message); + } +} + +test("링크 폼은 공백을 정리하고 기존 오류 문구를 유지한다", () => { + const result = linkFormSchema.safeParse({ + name: " eCampus ", + url: " https://ecampus.konkuk.ac.kr ", + iconId: 1, + }); + + assert.deepEqual(result, { + success: true, + data: { + name: "eCampus", + url: "https://ecampus.konkuk.ac.kr", + iconId: 1, + }, + }); + + expectError( + linkFormSchema.safeParse({ name: "", url: "https://example.com", iconId: 1 }), + "링크 이름을 입력해주세요.", + ); + expectError( + linkFormSchema.safeParse({ name: "링크", url: "not-a-url", iconId: 1 }), + "올바른 URL을 입력해주세요.", + ); + const maxLengthName = "가".repeat(LINK_NAME_MAX_LENGTH); + assert.equal( + linkFormSchema.safeParse({ + name: maxLengthName, + url: "https://example.com", + iconId: 1, + }).success, + true, + ); + expectError( + linkFormSchema.safeParse({ + name: `${maxLengthName}가`, + url: "https://example.com", + iconId: 1, + }), + `링크 이름은 ${LINK_NAME_MAX_LENGTH}자 이하로 입력해주세요.`, + ); + expectError( + linkFormSchema.safeParse({ name: "링크", url: "https://example.com", iconId: null }), + "아이콘을 선택해주세요.", + ); +}); + +test("건국대 이메일과 인증 코드는 형식과 도메인을 함께 검증한다", () => { + assert.deepEqual(konkukEmailSchema.safeParse(" student@konkuk.ac.kr "), { + success: true, + data: "student@konkuk.ac.kr", + }); + assert.deepEqual(konkukEmailSchema.safeParse("a..b@konkuk.ac.kr"), { + success: true, + data: "a..b@konkuk.ac.kr", + }); + assert.equal(konkukEmailSchema.safeParse("student@example.com").success, false); + assert.equal(authCodeSchema.safeParse("123456").success, true); + assert.equal(authCodeSchema.safeParse("12345a").success, false); +}); + +test("Todo, eCampus, QR 입력은 소비 전에 하나의 스키마로 검증한다", () => { + assert.deepEqual( + todoInputSchema.safeParse({ + title: " 과제 제출 ", + subject: " 자료구조 ", + dueDate: "2026-08-10", + dueTime: "23:59", + }), + { + success: true, + data: { + title: "과제 제출", + subject: "자료구조", + dueDate: "2026-08-10", + dueTime: "23:59", + }, + }, + ); + assert.equal( + todoInputSchema.safeParse({ + title: "", + subject: "", + dueDate: "2026-08-10", + dueTime: "23:59", + }).success, + false, + ); + assert.equal( + eCampusCredentialsSchema.safeParse({ userId: "student", userPw: "password" }).success, + true, + ); + assert.equal( + eCampusCredentialsSchema.safeParse({ userId: "", userPw: "password" }).success, + false, + ); + assert.deepEqual(qrUrlSchema.safeParse(" https://linku.turtlehwan.dev "), { + success: true, + data: "https://linku.turtlehwan.dev", + }); + assert.equal(qrUrlSchema.safeParse("not-a-url").success, false); +});