mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
fix(validation): added validation for inputs for signup & email, added tests (#438)
* add validation for signup/login fields * added validation for inputs for signup & email, added tests * acknowledged PR comments
This commit is contained in:
251
apps/sim/app/(auth)/login/login-form.test.tsx
Normal file
251
apps/sim/app/(auth)/login/login-form.test.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import LoginPage from './login-form'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
client: {
|
||||
signIn: {
|
||||
email: vi.fn(),
|
||||
},
|
||||
emailOtp: {
|
||||
sendVerificationOtp: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
|
||||
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useRouter as any).mockReturnValue(mockRouter)
|
||||
;(useSearchParams as any).mockReturnValue(mockSearchParams)
|
||||
mockSearchParams.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
githubAvailable: true,
|
||||
googleAvailable: true,
|
||||
isProduction: false,
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render login form with all required elements', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render social login buttons', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Visibility Toggle', () => {
|
||||
it('should toggle password visibility when button is clicked', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const toggleButton = screen.getByLabelText(/show password/i)
|
||||
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('should allow users to type in form fields', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com')
|
||||
expect(passwordInput).toHaveValue('password123')
|
||||
})
|
||||
|
||||
it('should show loading state during form submission', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
mockSignIn.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Signing in...')).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call signIn with correct credentials', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
mockSignIn.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignIn).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
callbackURL: '/w',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle authentication errors', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
|
||||
mockSignIn.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid credentials',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'Invalid credentials' })
|
||||
})
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid email or password')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forgot Password', () => {
|
||||
it('should open forgot password dialog', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const forgotPasswordButton = screen.getByText(/forgot password/i)
|
||||
fireEvent.click(forgotPasswordButton)
|
||||
|
||||
expect(screen.getByText('Reset Password')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Parameters', () => {
|
||||
it('should handle invite flow parameter in signup link', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'invite_flow') return 'true'
|
||||
if (param === 'callbackUrl') return '/invite/123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const signupLink = screen.getByText(/sign up/i)
|
||||
expect(signupLink).toHaveAttribute('href', '/signup?invite_flow=true&callbackUrl=/invite/123')
|
||||
})
|
||||
|
||||
it('should default to regular signup link when no invite flow', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const signupLink = screen.getByText(/sign up/i)
|
||||
expect(signupLink).toHaveAttribute('href', '/signup')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email Verification Flow', () => {
|
||||
it('should redirect to verification page when email not verified', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignIn.mockRejectedValue({
|
||||
message: 'Email not verified',
|
||||
code: 'EMAIL_NOT_VERIFIED',
|
||||
})
|
||||
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendOtp).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
type: 'email-verification',
|
||||
})
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,10 +10,37 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
const EMAIL_VALIDATIONS = {
|
||||
required: {
|
||||
test: (value: string) => Boolean(value && typeof value === 'string'),
|
||||
message: 'Email is required.',
|
||||
},
|
||||
notEmpty: {
|
||||
test: (value: string) => value.trim().length > 0,
|
||||
message: 'Email cannot be empty.',
|
||||
},
|
||||
basicFormat: {
|
||||
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: 'Please enter a valid email address.',
|
||||
},
|
||||
}
|
||||
|
||||
const PASSWORD_VALIDATIONS = {
|
||||
required: {
|
||||
test: (value: string) => Boolean(value && typeof value === 'string'),
|
||||
message: 'Password is required.',
|
||||
},
|
||||
notEmpty: {
|
||||
test: (value: string) => value.trim().length > 0,
|
||||
message: 'Password cannot be empty.',
|
||||
},
|
||||
}
|
||||
|
||||
// Validate callback URL to prevent open redirect vulnerabilities
|
||||
const validateCallbackUrl = (url: string): boolean => {
|
||||
try {
|
||||
@@ -35,6 +62,44 @@ const validateCallbackUrl = (url: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email and return array of error messages
|
||||
const validateEmail = (emailValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!EMAIL_VALIDATIONS.required.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.basicFormat.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// Validate password and return array of error messages
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export default function LoginPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
@@ -66,6 +131,11 @@ export default function LoginPage({
|
||||
message: string
|
||||
}>({ type: null, message: '' })
|
||||
|
||||
// Email validation state
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
|
||||
// Extract URL parameters after component mounts to avoid SSR issues
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -101,6 +171,26 @@ export default function LoginPage({
|
||||
}
|
||||
}, [forgotPasswordEmail, forgotPasswordOpen])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateEmail(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
@@ -108,6 +198,22 @@ export default function LoginPage({
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const email = formData.get('email') as string
|
||||
|
||||
// Validate email on submit
|
||||
const emailValidationErrors = validateEmail(email)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
// Validate password on submit
|
||||
const passwordValidationErrors = validatePassword(password)
|
||||
setPasswordErrors(passwordValidationErrors)
|
||||
setShowValidationError(passwordValidationErrors.length > 0)
|
||||
|
||||
// If there are validation errors, stop submission
|
||||
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Final validation before submission
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/w'
|
||||
@@ -290,12 +396,25 @@ export default function LoginPage({
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
className='border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -321,14 +440,13 @@ export default function LoginPage({
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (showValidationError) {
|
||||
setShowValidationError(false)
|
||||
setPasswordErrors([])
|
||||
}
|
||||
}}
|
||||
className='border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60'
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
|
||||
442
apps/sim/app/(auth)/signup/signup-form.test.tsx
Normal file
442
apps/sim/app/(auth)/signup/signup-form.test.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import SignupPage from './signup-form'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
client: {
|
||||
signUp: {
|
||||
email: vi.fn(),
|
||||
},
|
||||
emailOtp: {
|
||||
sendVerificationOtp: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
|
||||
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
}
|
||||
|
||||
describe('SignupPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useRouter as any).mockReturnValue(mockRouter)
|
||||
;(useSearchParams as any).mockReturnValue(mockSearchParams)
|
||||
mockSearchParams.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
githubAvailable: true,
|
||||
googleAvailable: true,
|
||||
isProduction: false,
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render signup form with all required elements', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText(/enter your name/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign in/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render social login buttons', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Visibility Toggle', () => {
|
||||
it('should toggle password visibility when button is clicked', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const toggleButton = screen.getByLabelText(/show password/i)
|
||||
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('should allow users to type in form fields', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
|
||||
expect(nameInput).toHaveValue('John Doe')
|
||||
expect(emailInput).toHaveValue('test@example.com')
|
||||
expect(passwordInput).toHaveValue('Password123!')
|
||||
})
|
||||
|
||||
it('should show loading state during form submission', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Creating account...')).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call signUp with correct credentials and trimmed name', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
// Use valid input that passes all validation rules
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
name: 'John Doe',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should prevent submission with invalid name validation', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
// Use name with leading/trailing spaces which should fail validation
|
||||
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
// Should not call signUp because validation failed
|
||||
expect(mockSignUp).not.toHaveBeenCalled()
|
||||
|
||||
// Should show validation error
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Name cannot contain consecutive spaces|Name cannot start or end with spaces/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to verification page after successful signup', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendOtp).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
type: 'email-verification',
|
||||
})
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle signup errors', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
mockSignUp.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'USER_ALREADY_EXISTS',
|
||||
message: 'User already exists',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'User already exists' })
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show warning for names that would be truncated (over 100 characters)', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const longName = 'a'.repeat(101) // 101 characters
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: longName } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name will be truncated to 100 characters/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Ensure signUp was not called
|
||||
expect(mockSignUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle names exactly at 100 characters without warning', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
const exactLengthName = 'a'.repeat(100) // Exactly 100 characters
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: exactLengthName } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
// Should not show truncation warning
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/name will be truncated/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should proceed with form submission
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'test@example.com',
|
||||
password: 'ValidPass123!',
|
||||
name: exactLengthName,
|
||||
},
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle names exactly at validation errors', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
mockSignUp.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'NAME_VALIDATION_ERROR',
|
||||
message: 'Name validation error',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'Name validation error' })
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Parameters', () => {
|
||||
it('should prefill email from URL parameter', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'email') return 'prefilled@example.com'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
expect(emailInput).toHaveValue('prefilled@example.com')
|
||||
})
|
||||
|
||||
it('should handle invite flow redirect', async () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'redirect') return '/invite/123'
|
||||
if (param === 'invite_flow') return 'true'
|
||||
return null
|
||||
})
|
||||
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/invite/123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle waitlist token verification', async () => {
|
||||
const mockFetch = vi.mocked(global.fetch)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
email: 'waitlist@example.com',
|
||||
}),
|
||||
} as Response)
|
||||
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'token') return 'waitlist-token-123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/auth/verify-waitlist-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: 'waitlist-token-123' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to login with invite flow parameters', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'invite_flow') return 'true'
|
||||
if (param === 'redirect') return '/invite/123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const loginLink = screen.getByText(/sign in/i)
|
||||
expect(loginLink).toHaveAttribute('href', '/login?invite_flow=true&callbackUrl=/invite/123')
|
||||
})
|
||||
|
||||
it('should default to regular login link when no invite flow', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const loginLink = screen.getByText(/sign in/i)
|
||||
expect(loginLink).toHaveAttribute('href', '/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,56 @@ const PASSWORD_VALIDATIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
const NAME_VALIDATIONS = {
|
||||
required: {
|
||||
test: (value: string) => Boolean(value && typeof value === 'string'),
|
||||
message: 'Name is required.',
|
||||
},
|
||||
notEmpty: {
|
||||
test: (value: string) => value.trim().length > 0,
|
||||
message: 'Name cannot be empty.',
|
||||
},
|
||||
validCharacters: {
|
||||
regex: /^[\p{L}\s\-']+$/u,
|
||||
message: 'Name can only contain letters, spaces, hyphens, and apostrophes.',
|
||||
},
|
||||
noConsecutiveSpaces: {
|
||||
regex: /^(?!.*\s\s).*$/,
|
||||
message: 'Name cannot contain consecutive spaces.',
|
||||
},
|
||||
noLeadingTrailingSpaces: {
|
||||
test: (value: string) => value === value.trim(),
|
||||
message: 'Name cannot start or end with spaces.',
|
||||
},
|
||||
}
|
||||
|
||||
const EMAIL_VALIDATIONS = {
|
||||
required: {
|
||||
test: (value: string) => Boolean(value && typeof value === 'string'),
|
||||
message: 'Email is required.',
|
||||
},
|
||||
notEmpty: {
|
||||
test: (value: string) => value.trim().length > 0,
|
||||
message: 'Email cannot be empty.',
|
||||
},
|
||||
maxLength: {
|
||||
test: (value: string) => value.length <= 254,
|
||||
message: 'Email must be less than 254 characters.',
|
||||
},
|
||||
basicFormat: {
|
||||
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: 'Please enter a valid email address.',
|
||||
},
|
||||
noSpaces: {
|
||||
regex: /^[^\s]*$/,
|
||||
message: 'Email cannot contain spaces.',
|
||||
},
|
||||
validStart: {
|
||||
regex: /^[a-zA-Z0-9]/,
|
||||
message: 'Email must start with a letter or number.',
|
||||
},
|
||||
}
|
||||
|
||||
function SignupFormContent({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
@@ -47,10 +97,17 @@ function SignupFormContent({
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [waitlistToken, setWaitlistToken] = useState('')
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
|
||||
// Name validation state
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [showNameValidationError, setShowNameValidationError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const emailParam = searchParams.get('email')
|
||||
@@ -110,7 +167,6 @@ function SignupFormContent({
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check each validation criteria
|
||||
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.minLength.message)
|
||||
}
|
||||
@@ -134,6 +190,68 @@ function SignupFormContent({
|
||||
return errors
|
||||
}
|
||||
|
||||
// Validate name and return array of error messages
|
||||
const validateName = (nameValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!NAME_VALIDATIONS.required.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
|
||||
errors.push(NAME_VALIDATIONS.validCharacters.message)
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.noLeadingTrailingSpaces.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.noLeadingTrailingSpaces.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// Validate email and return array of error messages
|
||||
const validateEmail = (emailValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!EMAIL_VALIDATIONS.required.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.maxLength.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.maxLength.message)
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.noSpaces.regex.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.noSpaces.message)
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.validStart.regex.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.validStart.message)
|
||||
}
|
||||
|
||||
if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) {
|
||||
errors.push(EMAIL_VALIDATIONS.basicFormat.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
@@ -144,9 +262,26 @@ function SignupFormContent({
|
||||
setShowValidationError(false)
|
||||
}
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value
|
||||
setName(newName)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateName(newName)
|
||||
setNameErrors(errors)
|
||||
setShowNameValidationError(false)
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
// Clear any previous email errors when the user starts typing
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateEmail(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
|
||||
// Clear any previous server-side email errors when the user starts typing
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
@@ -161,6 +296,16 @@ function SignupFormContent({
|
||||
const passwordValue = formData.get('password') as string
|
||||
const name = formData.get('name') as string
|
||||
|
||||
// Validate name on submit
|
||||
const nameValidationErrors = validateName(name)
|
||||
setNameErrors(nameValidationErrors)
|
||||
setShowNameValidationError(nameValidationErrors.length > 0)
|
||||
|
||||
// Validate email on submit
|
||||
const emailValidationErrors = validateEmail(emailValue)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
// Validate password on submit
|
||||
const errors = validatePassword(passwordValue)
|
||||
setPasswordErrors(errors)
|
||||
@@ -169,19 +314,44 @@ function SignupFormContent({
|
||||
setShowValidationError(errors.length > 0)
|
||||
|
||||
try {
|
||||
if (errors.length > 0) {
|
||||
// Show first error as notification
|
||||
setPasswordErrors([errors[0]])
|
||||
setShowValidationError(true)
|
||||
if (
|
||||
nameValidationErrors.length > 0 ||
|
||||
emailValidationErrors.length > 0 ||
|
||||
errors.length > 0
|
||||
) {
|
||||
// Prioritize name errors first, then email errors, then password errors
|
||||
if (nameValidationErrors.length > 0) {
|
||||
setNameErrors([nameValidationErrors[0]])
|
||||
setShowNameValidationError(true)
|
||||
}
|
||||
if (emailValidationErrors.length > 0) {
|
||||
setEmailErrors([emailValidationErrors[0]])
|
||||
setShowEmailValidationError(true)
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
setPasswordErrors([errors[0]])
|
||||
setShowValidationError(true)
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if name will be truncated and warn user
|
||||
const trimmedName = name.trim()
|
||||
if (trimmedName.length > 100) {
|
||||
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
|
||||
setShowNameValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const sanitizedName = trimmedName
|
||||
|
||||
const response = await client.signUp.email(
|
||||
{
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
name,
|
||||
name: sanitizedName,
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
@@ -311,12 +481,26 @@ function SignupFormContent({
|
||||
id='name'
|
||||
name='name'
|
||||
placeholder='Enter your name'
|
||||
required
|
||||
type='text'
|
||||
autoCapitalize='words'
|
||||
autoComplete='name'
|
||||
className='border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60'
|
||||
title='Name can only contain letters, spaces, hyphens, and apostrophes'
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showNameValidationError && nameErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email' className='text-neutral-300'>
|
||||
@@ -326,8 +510,6 @@ function SignupFormContent({
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
@@ -335,10 +517,18 @@ function SignupFormContent({
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
emailError && 'border-red-500 focus-visible:ring-red-500'
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{emailError && (
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{emailError && !showEmailValidationError && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{emailError}</p>
|
||||
</div>
|
||||
@@ -352,7 +542,6 @@ function SignupFormContent({
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Executor Class Unit Tests
|
||||
*
|
||||
@@ -22,7 +22,19 @@ import {
|
||||
import { Executor } from './index'
|
||||
import type { BlockLog } from './types'
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@/stores/execution/store', () => ({
|
||||
useExecutionStore: {
|
||||
getState: vi.fn(() => ({
|
||||
setIsExecuting: vi.fn(),
|
||||
setIsDebugging: vi.fn(),
|
||||
setPendingBlocks: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
setActiveBlocks: vi.fn(),
|
||||
})),
|
||||
setState: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: () => ({
|
||||
error: vi.fn(),
|
||||
@@ -35,7 +47,6 @@ vi.mock('@/lib/logs/console-logger', () => ({
|
||||
describe('Executor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Setup all standard mocks by default
|
||||
setupAllMocks()
|
||||
})
|
||||
|
||||
@@ -88,7 +99,6 @@ describe('Executor', () => {
|
||||
*/
|
||||
describe('workflow validation', () => {
|
||||
test('should validate workflow on initialization', () => {
|
||||
// Create a spy for the validateWorkflow method
|
||||
const validateSpy = vi.spyOn(Executor.prototype as any, 'validateWorkflow')
|
||||
|
||||
const workflow = createMinimalWorkflow()
|
||||
@@ -101,7 +111,6 @@ describe('Executor', () => {
|
||||
const workflow = createMinimalWorkflow()
|
||||
const executor = new Executor(workflow)
|
||||
|
||||
// Create a spy for the validateWorkflow method and reset the mock
|
||||
const validateSpy = vi.spyOn(executor as any, 'validateWorkflow')
|
||||
validateSpy.mockClear()
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
include: ['**/*.test.{ts,tsx}'],
|
||||
exclude: [...configDefaults.exclude, '**/node_modules/**', '**/dist/**'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
alias: {
|
||||
'@': resolve(__dirname, './'),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterAll, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn(() =>
|
||||
@@ -35,6 +35,8 @@ vi.mock('@/stores/execution/store', () => ({
|
||||
useExecutionStore: {
|
||||
getState: vi.fn().mockReturnValue({
|
||||
setIsExecuting: vi.fn(),
|
||||
setIsDebugging: vi.fn(),
|
||||
setPendingBlocks: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
setActiveBlocks: vi.fn(),
|
||||
}),
|
||||
|
||||
15
bun.lock
15
bun.lock
@@ -137,8 +137,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
@@ -1251,6 +1252,8 @@
|
||||
|
||||
"@types/jest": ["@types/jest@26.0.24", "", { "dependencies": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" } }, "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w=="],
|
||||
|
||||
"@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.16", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="],
|
||||
@@ -1285,6 +1288,8 @@
|
||||
|
||||
"@types/through": ["@types/through@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
|
||||
|
||||
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@15.0.19", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA=="],
|
||||
@@ -1693,7 +1698,7 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
"entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
@@ -3217,6 +3222,8 @@
|
||||
|
||||
"cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||
@@ -3247,6 +3254,8 @@
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
|
||||
|
||||
"jaeger-client/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
@@ -3305,8 +3314,6 @@
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
|
||||
|
||||
"pdf-parse/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
Reference in New Issue
Block a user