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( ); expect(screen.queryByRole('dialog')).toBeNull(); rerender(); 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( ); 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(); 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(); 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(); 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(resolve => { resolveFetch = resolve; }); const mockFetch = vi.fn(() => fetchPromise); global.fetch = mockFetch; render(); 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(); 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(); 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(); 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( ); 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( ); 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'); }); });