import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MockAdapter from 'axios-mock-adapter'; import { apiClient } from '@/api/client'; import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; import { renderWithProviders } from './utils'; import type { MitreTechnique } from '@/api/types'; const TECHNIQUES: MitreTechnique[] = [ { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] }, { id: 'T1059.001', name: 'PowerShell', tactics: ['execution'] }, { id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] }, ]; describe('MitreTechniquePicker', () => { let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(apiClient); vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { mock.restore(); vi.useRealTimers(); }); it('renders input with placeholder', () => { vi.useRealTimers(); renderWithProviders( , ); expect(screen.getByRole('combobox')).toBeInTheDocument(); expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument(); }); it('shows preselected value when techniqueId and name provided', () => { vi.useRealTimers(); renderWithProviders( , ); const input = screen.getByRole('combobox') as HTMLInputElement; expect(input.value).toContain('T1059'); expect(input.value).toContain('Command and Scripting Interpreter'); }); it('is disabled when disabled prop is true', () => { vi.useRealTimers(); renderWithProviders( , ); expect(screen.getByRole('combobox')).toBeDisabled(); }); it('debounces search: no request fires before 200ms', async () => { mock.onGet('/mitre/techniques').reply(200, TECHNIQUES); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox'); await user.click(input); await user.type(input, 'T'); // Before debounce fires expect(mock.history.get.length).toBe(0); // Advance past debounce act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => expect(mock.history.get.length).toBeGreaterThan(0)); }); it('displays results in dropdown after debounce', async () => { mock.onGet('/mitre/techniques').reply(200, TECHNIQUES); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox'); await user.click(input); await user.type(input, 'T1059'); act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => { expect(screen.getByRole('listbox')).toBeInTheDocument(); }); const options = screen.getAllByRole('option'); expect(options.length).toBeGreaterThan(0); expect(options[0].textContent).toContain('T1059'); }); it('selecting a result calls onChange with id and name', async () => { mock.onGet('/mitre/techniques').reply(200, TECHNIQUES); const onChange = vi.fn(); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox'); await user.click(input); await user.type(input, 'T1059'); act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => screen.getByRole('listbox')); const options = screen.getAllByRole('option'); await user.click(options[0]); expect(onChange).toHaveBeenCalledWith('T1059', 'Command and Scripting Interpreter'); }); it('populates input display string after selection', async () => { mock.onGet('/mitre/techniques').reply(200, TECHNIQUES); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox') as HTMLInputElement; await user.click(input); await user.type(input, 'T1059'); act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => screen.getByRole('listbox')); const options = screen.getAllByRole('option'); await user.click(options[0]); expect(input.value).toContain('T1059'); expect(input.value).toContain('Command and Scripting Interpreter'); }); it('keyboard ArrowDown + Enter selects item', async () => { mock.onGet('/mitre/techniques').reply(200, TECHNIQUES); const onChange = vi.fn(); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox'); await user.click(input); await user.type(input, 'T105'); act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => screen.getByRole('listbox')); await user.keyboard('{ArrowDown}'); await user.keyboard('{Enter}'); expect(onChange).toHaveBeenCalled(); }); it('Escape closes the dropdown', async () => { mock.onGet('/mitre/techniques').reply(200, TECHNIQUES); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox'); await user.click(input); await user.type(input, 'T1059'); act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => screen.getByRole('listbox')); await user.keyboard('{Escape}'); expect(screen.queryByRole('listbox')).toBeNull(); }); it('shows inline error when API returns 503', async () => { mock.onGet('/mitre/techniques').reply(503, { error: 'mitre bundle not loaded' }); const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); renderWithProviders( , ); const input = screen.getByRole('combobox'); await user.click(input); await user.type(input, 'T1059'); act(() => { vi.advanceTimersByTime(300); }); await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument(); }); }); });