369 lines
14 KiB
TypeScript
369 lines
14 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 userEvent from '@testing-library/user-event';
|
|
import { SupportReportModal } from '../components/ops/support-report-modal';
|
|
|
|
describe('SupportReportModal Component Tests', () => {
|
|
const originalFetch = global.fetch;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
// ============================================================
|
|
// A. Visibility and lifecycle
|
|
// ============================================================
|
|
it('does not render dialog when isOpen=false, renders form when isOpen=true, and Escape calls onClose', async () => {
|
|
const onClose = vi.fn();
|
|
const onSuccess = vi.fn();
|
|
|
|
const { rerender } = render(
|
|
<SupportReportModal isOpen={false} onClose={onClose} onSuccess={onSuccess} />
|
|
);
|
|
expect(screen.queryByRole('dialog')).toBeNull();
|
|
|
|
rerender(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
|
const dialog = screen.getByRole('dialog');
|
|
expect(dialog).toBeDefined();
|
|
expect(screen.getByRole('heading', { name: /new support report/i })).toBeDefined();
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ============================================================
|
|
// B. Canonical ticket types
|
|
// ============================================================
|
|
it('renders all four canonical ticket types and excludes deprecated billing or data_deletion', () => {
|
|
render(
|
|
<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />
|
|
);
|
|
|
|
const select = screen.getByLabelText(/ticket type/i) as HTMLSelectElement;
|
|
expect(select).toBeDefined();
|
|
|
|
const optionValues = Array.from(select.options).map(opt => opt.value);
|
|
const optionLabels = Array.from(select.options).map(opt => opt.textContent);
|
|
|
|
expect(optionValues).toEqual(['general', 'bug_report', 'telemetry_issue', 'contact_request']);
|
|
expect(optionLabels).toEqual(['General', 'Bug Report', 'Telemetry Issue', 'Contact Request']);
|
|
|
|
expect(optionValues).not.toContain('billing');
|
|
expect(optionValues).not.toContain('data_deletion');
|
|
});
|
|
|
|
// ============================================================
|
|
// C. Whitespace validation
|
|
// ============================================================
|
|
it('rejects whitespace-only subject without calling fetch and displays validation error', async () => {
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: ' ' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Valid description text' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
expect(screen.getByText('Subject cannot be empty or contain only whitespace.')).toBeDefined();
|
|
});
|
|
|
|
it('rejects whitespace-only description without calling fetch and displays validation error', async () => {
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Valid subject title' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: ' \n \t ' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
expect(screen.getByText('Description cannot be empty or contain only whitespace.')).toBeDefined();
|
|
});
|
|
|
|
// ============================================================
|
|
// D. Trimmed payload
|
|
// ============================================================
|
|
it('submits trimmed values and canonical ticket_type and severity to the backend API', async () => {
|
|
let capturedUrl = '';
|
|
let capturedOptions: RequestInit | undefined;
|
|
|
|
global.fetch = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
|
|
capturedUrl = url.toString();
|
|
capturedOptions = options;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
id: 'sup-12345',
|
|
case_reference: 'SUP-2026-000001',
|
|
ticket_type: 'telemetry_issue',
|
|
status: 'open',
|
|
severity: 'high',
|
|
reporter_email: 'driver@example.com',
|
|
subject: 'Speed sensor intermittent dropout',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
has_diagnostics: false,
|
|
notes_count: 0,
|
|
}),
|
|
} as Response;
|
|
});
|
|
|
|
const onSuccess = vi.fn();
|
|
const onClose = vi.fn();
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), {
|
|
target: { value: ' driver@example.com ' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/ticket type/i), {
|
|
target: { value: 'telemetry_issue' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/severity/i), {
|
|
target: { value: 'high' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/subject/i), {
|
|
target: { value: ' Speed sensor intermittent dropout ' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/description/i), {
|
|
target: { value: ' Telemetry packet dropped at 60mph on turn 4 ' },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
|
|
|
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
|
|
|
expect(capturedUrl).toBe('/api/ops/support/tickets');
|
|
expect(capturedOptions?.method).toBe('POST');
|
|
expect(capturedOptions?.headers).toEqual({ 'Content-Type': 'application/json' });
|
|
|
|
const parsedBody = JSON.parse(capturedOptions?.body as string);
|
|
expect(parsedBody).toEqual({
|
|
ticket_type: 'telemetry_issue',
|
|
reporter_email: 'driver@example.com',
|
|
subject: 'Speed sensor intermittent dropout',
|
|
description: 'Telemetry packet dropped at 60mph on turn 4',
|
|
severity: 'high',
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// E. Duplicate-submit prevention
|
|
// ============================================================
|
|
it('prevents duplicate submissions while a request is pending and disables the submit button', async () => {
|
|
let resolveFetch!: (value: Response) => void;
|
|
const fetchPromise = new Promise<Response>(resolve => {
|
|
resolveFetch = resolve;
|
|
});
|
|
|
|
const mockFetch = vi.fn(() => fetchPromise);
|
|
global.fetch = mockFetch;
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'GPS sync failed' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'GPS fix took 45 seconds.' } });
|
|
|
|
const submitBtn = screen.getByRole('button', { name: /create ticket/i }) as HTMLButtonElement;
|
|
|
|
fireEvent.click(submitBtn);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
|
|
// Button should now be disabled and show "Submitting..."
|
|
expect(submitBtn.disabled).toBe(true);
|
|
expect(submitBtn.textContent).toContain('Submitting...');
|
|
|
|
// Attempt second submission while first is still pending
|
|
fireEvent.click(submitBtn);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
|
|
// Resolve the pending fetch
|
|
resolveFetch({
|
|
ok: true,
|
|
json: async () => ({ id: 'ticket-999' }),
|
|
} as Response);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// F. Successful submission
|
|
// ============================================================
|
|
it('calls onSuccess with the returned ticket summary and closes modal on success', async () => {
|
|
const ticketSummary = {
|
|
id: 'ticket-abc-123',
|
|
case_reference: 'SUP-2026-000100',
|
|
ticket_type: 'general',
|
|
status: 'open',
|
|
severity: 'medium',
|
|
reporter_email: 'rider@example.com',
|
|
subject: 'Question on Bluetooth OBD link',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
has_diagnostics: false,
|
|
notes_count: 0,
|
|
};
|
|
|
|
global.fetch = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ticketSummary,
|
|
} as Response));
|
|
|
|
const onSuccess = vi.fn();
|
|
const onClose = vi.fn();
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'rider@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Question on Bluetooth OBD link' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Pairing instructions inquiry.' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
|
|
|
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(ticketSummary));
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ============================================================
|
|
// G. API failure
|
|
// ============================================================
|
|
it('displays API error detail on non-2xx response, does not call onSuccess, and releases submitting state', async () => {
|
|
global.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
status: 422,
|
|
json: async () => ({ detail: 'Invalid ticket type or email address' }),
|
|
} as Response));
|
|
|
|
const onSuccess = vi.fn();
|
|
const onClose = vi.fn();
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'rider@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Test subject' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Test description' } });
|
|
|
|
const submitBtn = screen.getByRole('button', { name: /create ticket/i }) as HTMLButtonElement;
|
|
fireEvent.click(submitBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Invalid ticket type or email address')).toBeDefined();
|
|
});
|
|
|
|
expect(onSuccess).not.toHaveBeenCalled();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(submitBtn.disabled).toBe(false);
|
|
expect(submitBtn.textContent).toContain('Create Ticket');
|
|
});
|
|
|
|
// ============================================================
|
|
// H. Network failure
|
|
// ============================================================
|
|
it('displays safe user-facing error on network exception and does not leak internal error message', async () => {
|
|
global.fetch = vi.fn(async () => {
|
|
throw new Error('ECONNREFUSED 127.0.0.1:8080 internal crash');
|
|
});
|
|
|
|
const onSuccess = vi.fn();
|
|
const onClose = vi.fn();
|
|
|
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
|
|
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Network test' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Network error test desc' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Network error during submission')).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByText(/ECONNREFUSED/)).toBeNull();
|
|
expect(screen.queryByText(/internal crash/)).toBeNull();
|
|
expect(onSuccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ============================================================
|
|
// I. Default reporter email pre-filling and clear button
|
|
// ============================================================
|
|
it('pre-fills reporter_email with defaultReporterEmail and shows clear button', () => {
|
|
render(
|
|
<SupportReportModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
onSuccess={vi.fn()}
|
|
defaultReporterEmail="admin@motorsignal.app"
|
|
/>
|
|
);
|
|
|
|
const emailInput = screen.getByLabelText(/^reporter email$/i) as HTMLInputElement;
|
|
expect(emailInput.value).toBe('admin@motorsignal.app');
|
|
|
|
const clearButton = screen.getByRole('button', { name: /clear email/i });
|
|
expect(clearButton).toBeDefined();
|
|
|
|
fireEvent.click(clearButton);
|
|
expect(emailInput.value).toBe('');
|
|
expect(screen.queryByRole('button', { name: /clear email/i })).toBeNull();
|
|
});
|
|
|
|
it('submits pre-filled defaultReporterEmail when user does not modify it', async () => {
|
|
let capturedOptions: RequestInit | undefined;
|
|
global.fetch = vi.fn(async (_url: string | URL | Request, options?: RequestInit) => {
|
|
capturedOptions = options;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
id: 'sup-auto-1',
|
|
case_reference: 'SUP-2026-000105',
|
|
ticket_type: 'general',
|
|
status: 'open',
|
|
severity: 'medium',
|
|
reporter_email: 'operator@motorsignal.com',
|
|
subject: 'Auto email test',
|
|
description: 'Testing prefilled email',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
has_diagnostics: false,
|
|
notes_count: 0,
|
|
}),
|
|
} as Response;
|
|
});
|
|
|
|
const onSuccess = vi.fn();
|
|
render(
|
|
<SupportReportModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
onSuccess={onSuccess}
|
|
defaultReporterEmail="operator@motorsignal.com"
|
|
/>
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Auto email test' } });
|
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Testing prefilled email' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
|
|
|
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
|
const parsedBody = JSON.parse(capturedOptions?.body as string);
|
|
expect(parsedBody.reporter_email).toBe('operator@motorsignal.com');
|
|
});
|
|
});
|