MotorSignal-App/website/tests/support-report-modal.test.tsx

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