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);
+});