197 lines
6.6 KiB
TypeScript
197 lines
6.6 KiB
TypeScript
import React from 'react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { SegmentedOtpInput } from '../components/segmented-otp-input';
|
|
import { VerifyEmailForm } from '../components/verify-email-form';
|
|
import { RecoveryForm } from '../components/recovery-form';
|
|
|
|
describe('SegmentedOtpInput Component (WEB-22 / WEB-21)', () => {
|
|
it('renders 6 distinct numeric input elements', () => {
|
|
const onChange = vi.fn();
|
|
render(<SegmentedOtpInput value="" onChange={onChange} length={6} />);
|
|
|
|
const inputs = screen.getAllByRole('textbox');
|
|
expect(inputs).toHaveLength(6);
|
|
expect(inputs[0].getAttribute('inputmode')).toBe('numeric');
|
|
});
|
|
|
|
it('populates initial digits from value prop', () => {
|
|
render(<SegmentedOtpInput value="123456" onChange={vi.fn()} length={6} />);
|
|
|
|
const inputs = screen.getAllByRole('textbox') as HTMLInputElement[];
|
|
expect(inputs.map((i) => i.value)).toEqual(['1', '2', '3', '4', '5', '6']);
|
|
});
|
|
|
|
it('updates value and advances focus on typing', () => {
|
|
const onChange = vi.fn();
|
|
render(<SegmentedOtpInput value="" onChange={onChange} length={6} />);
|
|
|
|
const inputs = screen.getAllByRole('textbox') as HTMLInputElement[];
|
|
fireEvent.change(inputs[0], { target: { value: '9' } });
|
|
|
|
expect(onChange).toHaveBeenCalledWith('9');
|
|
});
|
|
|
|
it('handles paste event correctly across all 6 inputs', () => {
|
|
const onChange = vi.fn();
|
|
render(<SegmentedOtpInput value="" onChange={onChange} length={6} />);
|
|
|
|
const inputs = screen.getAllByRole('textbox');
|
|
fireEvent.paste(inputs[0], {
|
|
clipboardData: {
|
|
getData: () => '849201',
|
|
},
|
|
});
|
|
|
|
expect(onChange).toHaveBeenCalledWith('849201');
|
|
});
|
|
});
|
|
|
|
describe('VerifyEmailForm Component (WEB-22)', () => {
|
|
const originalFetch = global.fetch;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('renders tactical header with Verify Telemetry Signature and 6-digit OTP challenge', () => {
|
|
render(<VerifyEmailForm />);
|
|
|
|
expect(screen.getByRole('heading', { name: /verify telemetry signature/i })).toBeDefined();
|
|
const inputs = screen.getAllByRole('textbox');
|
|
expect(inputs).toHaveLength(6);
|
|
expect(screen.getByRole('button', { name: /confirm telemetry signature/i })).toBeDefined();
|
|
});
|
|
|
|
it('disables submit button until 6 digits are provided, then submits successfully', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
});
|
|
|
|
render(<VerifyEmailForm />);
|
|
|
|
const submitBtn = screen.getByRole('button', { name: /confirm telemetry signature/i }) as HTMLButtonElement;
|
|
expect(submitBtn.disabled).toBe(true);
|
|
|
|
const inputs = screen.getAllByRole('textbox') as HTMLInputElement[];
|
|
fireEvent.paste(inputs[0], {
|
|
clipboardData: {
|
|
getData: () => '654321',
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(submitBtn.disabled).toBe(false);
|
|
});
|
|
|
|
fireEvent.click(submitBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/auth/verify-email', expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ token: '654321' }),
|
|
}));
|
|
expect(screen.getByText(/telemetry signature verified successfully/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('renders resend cooldown timer and handles resend verification code request', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ detail: 'Dispatched' }),
|
|
});
|
|
|
|
render(<VerifyEmailForm />);
|
|
|
|
expect(screen.getByText(/resend code in/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('RecoveryForm Component (WEB-21)', () => {
|
|
const originalFetch = global.fetch;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('renders step 1 with Recover Account Access and email input', () => {
|
|
render(<RecoveryForm />);
|
|
|
|
expect(screen.getByRole('heading', { name: /recover account access/i })).toBeDefined();
|
|
expect(screen.getByPlaceholderText('rider@example.com')).toBeDefined();
|
|
expect(screen.getByRole('button', { name: /send recovery code/i })).toBeDefined();
|
|
});
|
|
|
|
it('submits email in step 1 and advances to step 2 OTP challenge', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ detail: 'Sent' }),
|
|
});
|
|
|
|
render(<RecoveryForm />);
|
|
|
|
const emailInput = screen.getByPlaceholderText('rider@example.com');
|
|
fireEvent.change(emailInput, { target: { value: 'speed@motorsignal.app' } });
|
|
|
|
const sendBtn = screen.getByRole('button', { name: /send recovery code/i });
|
|
fireEvent.click(sendBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/auth/password-reset-request', expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: 'speed@motorsignal.app' }),
|
|
}));
|
|
expect(screen.getByText(/enter your 6-digit verification code/i)).toBeDefined();
|
|
expect(screen.getAllByPlaceholderText('••••••••••••')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it('validates password requirements and submits password reset', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
});
|
|
|
|
render(<RecoveryForm initialToken="123456" />);
|
|
|
|
expect(screen.getByText(/6-digit recovery code/i)).toBeDefined();
|
|
|
|
const passwordInputs = screen.getAllByPlaceholderText('••••••••••••') as HTMLInputElement[];
|
|
expect(passwordInputs).toHaveLength(2);
|
|
|
|
// Enter short password
|
|
fireEvent.change(passwordInputs[0], { target: { value: 'short' } });
|
|
fireEvent.change(passwordInputs[1], { target: { value: 'short' } });
|
|
|
|
const submitBtn = screen.getByRole('button', { name: /set new password/i });
|
|
fireEvent.click(submitBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/password must be at least 12 characters/i)).toBeDefined();
|
|
});
|
|
|
|
// Enter matching 12+ char password
|
|
fireEvent.change(passwordInputs[0], { target: { value: 'SuperSecret1234!' } });
|
|
fireEvent.change(passwordInputs[1], { target: { value: 'SuperSecret1234!' } });
|
|
|
|
fireEvent.click(submitBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/auth/password-reset', expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ token: '123456', password: 'SuperSecret1234!' }),
|
|
}));
|
|
expect(screen.getByText(/password reset complete/i)).toBeDefined();
|
|
});
|
|
});
|
|
});
|