diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index 3392826d..7fafd7c7 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -18,11 +18,18 @@ import type { NotificationResponse, EventMetadata, EventType, + AdminExecutionResponse, + QueueStatusResponse, + SagaStatusResponse, + UserResponse, } from '$lib/api'; export type UserEventInstance = ReturnType; -export const user: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); +export const user: UserEventInstance = userEvent.setup({ + delay: null, + pointerEventsCheck: 0, +}); // ============================================================================ // Mock Store Type (for use with vi.hoisted) @@ -236,3 +243,121 @@ export function createMockUserOverview(): AdminUserOverview { recent_events: [createMockEvent() as AdminUserOverview['recent_events'][number]], }; } + +// ============================================================================ +// Admin Execution Mock Helpers +// ============================================================================ + +export const DEFAULT_EXECUTION: AdminExecutionResponse = { + execution_id: 'exec-1', + script: 'print("hi")', + status: 'queued', + lang: 'python', + lang_version: '3.11', + priority: 'normal', + user_id: 'user-1', + stdout: null, + stderr: null, + exit_code: null, + error_type: null, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:30:00Z', +}; + +export const createMockExecution = (overrides: Partial = {}): AdminExecutionResponse => ({ + ...DEFAULT_EXECUTION, + ...overrides, +}); + +const EXECUTION_STATUSES: AdminExecutionResponse['status'][] = [ + 'queued', 'scheduled', 'running', 'completed', 'failed', 'timeout', 'cancelled', 'error', +]; +const EXECUTION_PRIORITIES: AdminExecutionResponse['priority'][] = [ + 'critical', 'high', 'normal', 'low', 'background', +]; + +export const createMockExecutions = (count: number): AdminExecutionResponse[] => + Array.from({ length: count }, (_, i) => createMockExecution({ + execution_id: `exec-${i + 1}`, + status: EXECUTION_STATUSES[i % EXECUTION_STATUSES.length], + priority: EXECUTION_PRIORITIES[i % EXECUTION_PRIORITIES.length], + user_id: `user-${(i % 3) + 1}`, + created_at: new Date(Date.now() - i * 60000).toISOString(), + })); + +export const createMockQueueStatus = (overrides: Partial = {}): QueueStatusResponse => ({ + queue_depth: 5, + active_count: 2, + max_concurrent: 10, + by_priority: { normal: 3, high: 2 }, + ...overrides, +}); + +// ============================================================================ +// Admin Saga Mock Helpers +// ============================================================================ + +export const DEFAULT_SAGA: SagaStatusResponse = { + saga_id: 'saga-1', + saga_name: 'execution_saga', + execution_id: 'exec-123', + state: 'running', + current_step: 'create_pod', + completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'], + compensated_steps: [], + retry_count: 0, + error_message: null, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:31:00Z', + completed_at: null, +}; + +export const createMockSaga = (overrides: Partial = {}): SagaStatusResponse => ({ + ...DEFAULT_SAGA, + ...overrides, +}); + +const SAGA_STATES: SagaStatusResponse['state'][] = [ + 'created', 'running', 'completed', 'failed', 'compensating', 'timeout', +]; + +export const createMockSagas = (count: number): SagaStatusResponse[] => + Array.from({ length: count }, (_, i) => createMockSaga({ + saga_id: `saga-${i + 1}`, + execution_id: `exec-${i + 1}`, + state: SAGA_STATES[i % SAGA_STATES.length], + created_at: new Date(Date.now() - i * 60000).toISOString(), + updated_at: new Date(Date.now() - i * 30000).toISOString(), + })); + +// ============================================================================ +// Admin User Mock Helpers +// ============================================================================ + +export const DEFAULT_USER: UserResponse = { + user_id: 'user-1', + username: 'testuser', + email: 'test@example.com', + role: 'user', + is_active: true, + is_superuser: false, + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-15T10:30:00Z', + bypass_rate_limit: false, + global_multiplier: 1.0, + has_custom_limits: false, +}; + +export const createMockUser = (overrides: Partial = {}): UserResponse => ({ + ...DEFAULT_USER, + ...overrides, +}); + +export const createMockUsers = (count: number): UserResponse[] => + Array.from({ length: count }, (_, i) => createMockUser({ + user_id: `user-${i + 1}`, + username: `user${i + 1}`, + email: `user${i + 1}@example.com`, + role: i === 0 ? 'admin' : 'user', + is_active: i % 3 !== 0, + })); diff --git a/frontend/src/components/__tests__/EventTypeIcon.test.ts b/frontend/src/components/__tests__/EventTypeIcon.test.ts new file mode 100644 index 00000000..78000c27 --- /dev/null +++ b/frontend/src/components/__tests__/EventTypeIcon.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/svelte'; +import EventTypeIcon from '$components/EventTypeIcon.svelte'; + +describe('EventTypeIcon', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('known event types', () => { + it.each([ + 'execution.requested', + 'execution_requested', + 'execution.started', + 'execution_started', + 'execution.completed', + 'execution_completed', + 'execution.failed', + 'execution_failed', + 'execution.timeout', + 'execution_timeout', + 'pod.created', + 'pod_created', + 'pod.running', + 'pod_running', + 'pod.succeeded', + 'pod_succeeded', + 'pod.failed', + 'pod_failed', + 'pod.terminated', + 'pod_terminated', + ])('renders SVG for "%s"', (eventType) => { + const { container } = render(EventTypeIcon, { props: { eventType } }); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg?.classList.contains('lucide-help-circle')).toBe(false); + }); + }); + + describe('unknown event type', () => { + it('renders fallback icon for unknown type', () => { + const { container } = render(EventTypeIcon, { props: { eventType: 'unknown.event' } }); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders a different icon than known types', () => { + const { container: unknownContainer } = render(EventTypeIcon, { + props: { eventType: 'unknown.event' }, + }); + const { container: knownContainer } = render(EventTypeIcon, { + props: { eventType: 'execution.started' }, + }); + expect(unknownContainer.querySelector('svg')?.innerHTML).not.toBe( + knownContainer.querySelector('svg')?.innerHTML + ); + }); + }); + + describe('size prop', () => { + it.each([ + { size: undefined, expected: '20', desc: 'defaults to 20' }, + { size: 32, expected: '32', desc: 'passes custom size' }, + ])('$desc', ({ size, expected }) => { + const { container } = render(EventTypeIcon, { + props: { eventType: 'execution.started', ...(size ? { size } : {}) }, + }); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', expected); + expect(svg).toHaveAttribute('height', expected); + }); + }); +}); diff --git a/frontend/src/components/__tests__/Modal.test.ts b/frontend/src/components/__tests__/Modal.test.ts new file mode 100644 index 00000000..f1750f48 --- /dev/null +++ b/frontend/src/components/__tests__/Modal.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import ModalWrapper from './ModalWrapper.svelte'; + +describe('Modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('open/closed', () => { + it.each([ + { open: true, visible: true }, + { open: false, visible: false }, + ])('content visible=$visible when open=$open', ({ open, visible }) => { + render(ModalWrapper, { props: { open } }); + if (visible) { + expect(screen.getByTestId('modal-body')).toBeInTheDocument(); + } else { + expect(screen.queryByTestId('modal-body')).not.toBeInTheDocument(); + } + }); + }); + + describe('accessibility', () => { + it('has correct dialog a11y attributes', () => { + render(ModalWrapper, { props: { open: true } }); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title'); + expect(screen.getByText('Test Modal')).toHaveAttribute('id', 'modal-title'); + expect(screen.getByRole('button', { name: 'Close modal' })).toBeInTheDocument(); + }); + }); + + describe('close interactions', () => { + it('fires onClose when X button clicked', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await user.click(screen.getByRole('button', { name: 'Close modal' })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('fires onClose on Escape keydown', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('fires onClose on backdrop click', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await fireEvent.click(screen.getByRole('dialog')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('does not fire onClose when clicking body content', async () => { + const onClose = vi.fn(); + render(ModalWrapper, { props: { open: true, onClose } }); + await user.click(screen.getByTestId('modal-body')); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('size classes', () => { + it.each([ + { size: 'sm' as const, expectedClass: 'max-w-md' }, + { size: 'md' as const, expectedClass: 'max-w-2xl' }, + { size: 'lg' as const, expectedClass: 'max-w-4xl' }, + { size: 'xl' as const, expectedClass: 'max-w-6xl' }, + ])('applies $expectedClass for size=$size', ({ size, expectedClass }) => { + const { container } = render(ModalWrapper, { props: { open: true, size } }); + expect(container.querySelector('.modal-container')?.classList.contains(expectedClass)).toBe(true); + }); + + it('defaults to lg (max-w-4xl)', () => { + const { container } = render(ModalWrapper, { props: { open: true } }); + expect(container.querySelector('.modal-container')?.classList.contains('max-w-4xl')).toBe(true); + }); + }); + + describe('footer', () => { + it.each([ + { showFooter: true, hasFooter: true }, + { showFooter: false, hasFooter: false }, + ])('footer present=$hasFooter when showFooter=$showFooter', ({ showFooter, hasFooter }) => { + const { container } = render(ModalWrapper, { props: { open: true, showFooter } }); + if (hasFooter) { + expect(screen.getByTestId('modal-footer-content')).toBeInTheDocument(); + } else { + expect(container.querySelector('.modal-footer')).not.toBeInTheDocument(); + } + }); + }); +}); diff --git a/frontend/src/components/__tests__/ModalWrapper.svelte b/frontend/src/components/__tests__/ModalWrapper.svelte new file mode 100644 index 00000000..634cdf1e --- /dev/null +++ b/frontend/src/components/__tests__/ModalWrapper.svelte @@ -0,0 +1,32 @@ + + +{#if showFooter} + +

Modal body content

+ {#snippet footer()} +
Footer content
+ {/snippet} +
+{:else} + +

Modal body content

+
+{/if} diff --git a/frontend/src/components/__tests__/Pagination.test.ts b/frontend/src/components/__tests__/Pagination.test.ts new file mode 100644 index 00000000..10a01e17 --- /dev/null +++ b/frontend/src/components/__tests__/Pagination.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import Pagination from '$components/Pagination.svelte'; + +const defaultProps = { + currentPage: 1, + totalPages: 5, + totalItems: 50, + pageSize: 10, + onPageChange: vi.fn(), +}; + +function renderPagination(overrides: Partial = {}) { + const props = { ...defaultProps, onPageChange: vi.fn(), ...overrides }; + return { ...render(Pagination, { props }), onPageChange: props.onPageChange }; +} + +describe('Pagination', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('page info text', () => { + it.each([ + { page: 1, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 1 - 10 of 50 items/ }, + { page: 3, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 21 - 30 of 50 items/ }, + { page: 3, totalPages: 3, totalItems: 23, pageSize: 10, expected: /Showing 21 - 23 of 23 items/ }, + ])('shows "$expected" for page $page of $totalPages ($totalItems items)', ({ page, totalPages, totalItems, pageSize, expected }) => { + renderPagination({ currentPage: page, totalPages, totalItems, pageSize }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('uses custom itemName', () => { + renderPagination({ itemName: 'events' } as Record); + expect(screen.getByText(/of 50 events/)).toBeInTheDocument(); + }); + }); + + describe('navigation buttons', () => { + it.each([ + { name: 'First page', expectedPage: 1 }, + { name: 'Previous page', expectedPage: 2 }, + { name: 'Next page', expectedPage: 4 }, + { name: 'Last page', expectedPage: 5 }, + ])('$name calls onPageChange($expectedPage)', async ({ name, expectedPage }) => { + const { onPageChange } = renderPagination({ currentPage: 3 }); + await user.click(screen.getByRole('button', { name })); + expect(onPageChange).toHaveBeenCalledWith(expectedPage); + }); + }); + + describe('disabled states', () => { + it.each([ + { page: 1, disabled: ['First page', 'Previous page'] }, + { page: 5, disabled: ['Next page', 'Last page'] }, + ])('disables $disabled on page $page', ({ page, disabled }) => { + renderPagination({ currentPage: page }); + for (const name of disabled) { + expect(screen.getByRole('button', { name })).toBeDisabled(); + } + }); + }); + + describe('page size selector', () => { + it('hidden when onPageSizeChange not provided', () => { + renderPagination(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('renders select with default options when onPageSizeChange provided', () => { + renderPagination({ onPageSizeChange: vi.fn() } as Record); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual([ + '10 / page', '25 / page', '50 / page', '100 / page', + ]); + }); + + it('fires onPageSizeChange on select change', async () => { + const onPageSizeChange = vi.fn(); + renderPagination({ onPageSizeChange } as Record); + await user.selectOptions(screen.getByRole('combobox'), '25'); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); + }); + + describe('visibility', () => { + it('renders nothing when totalPages=1 and no onPageSizeChange', () => { + const { container } = renderPagination({ totalPages: 1, totalItems: 5, pageSize: 10 }); + expect(container.querySelector('.pagination-container')).not.toBeInTheDocument(); + }); + + it('still shows when totalPages=1 if onPageSizeChange present', () => { + const { container } = renderPagination({ + totalPages: 1, totalItems: 5, pageSize: 10, + onPageSizeChange: vi.fn(), + } as Record); + expect(container.querySelector('.pagination-container')).toBeInTheDocument(); + }); + + it('hides nav buttons when totalPages=1', () => { + renderPagination({ + totalPages: 1, totalItems: 5, pageSize: 10, + onPageSizeChange: vi.fn(), + } as Record); + expect(screen.queryByRole('button', { name: 'First page' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/admin/__tests__/ActionButtons.test.ts b/frontend/src/components/admin/__tests__/ActionButtons.test.ts new file mode 100644 index 00000000..c648ae0a --- /dev/null +++ b/frontend/src/components/admin/__tests__/ActionButtons.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import type { Component } from 'svelte'; +import ActionButtons from '$components/admin/ActionButtons.svelte'; + +const MockIcon: Component = (() => ({})) as unknown as Component; + +function makeAction(overrides: Record = {}) { + return { + icon: MockIcon, + label: 'Test Action', + onclick: vi.fn(), + ...overrides, + }; +} + +describe('ActionButtons', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('variants', () => { + it('icon-only (default): renders button without visible label, with title', () => { + render(ActionButtons, { props: { actions: [makeAction()] } }); + const btn = screen.getByRole('button'); + expect(btn).not.toHaveTextContent('Test Action'); + expect(btn).toHaveAttribute('title', 'Test Action'); + }); + + it.each([ + { variant: 'with-text' as const, label: 'with-text' }, + { variant: 'mobile' as const, label: 'mobile' }, + ])('$label: renders button with visible label text', ({ variant }) => { + render(ActionButtons, { + props: { actions: [makeAction()], variant }, + }); + expect(screen.getByRole('button')).toHaveTextContent('Test Action'); + }); + + it('mobile: buttons have flex-1 for full width', () => { + render(ActionButtons, { + props: { actions: [makeAction()], variant: 'mobile' }, + }); + expect(screen.getByRole('button').classList.contains('flex-1')).toBe(true); + }); + }); + + describe('color classes', () => { + it.each([ + { color: 'primary', expectedClass: 'text-primary' }, + { color: 'success', expectedClass: 'text-green-600' }, + { color: 'danger', expectedClass: 'text-red-600' }, + { color: 'warning', expectedClass: 'text-yellow-600' }, + { color: 'info', expectedClass: 'text-blue-600' }, + { color: undefined, expectedClass: 'text-fg-muted' }, + ])('applies $expectedClass for color=$color', ({ color, expectedClass }) => { + render(ActionButtons, { + props: { actions: [makeAction({ color })] }, + }); + expect(screen.getByRole('button').className).toContain(expectedClass); + }); + }); + + describe('disabled state', () => { + it('sets disabled attribute and opacity class', () => { + render(ActionButtons, { + props: { actions: [makeAction({ disabled: true })] }, + }); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + expect(btn.className).toContain('opacity-50'); + }); + + it('does not fire onclick when disabled', async () => { + const onclick = vi.fn(); + render(ActionButtons, { + props: { actions: [makeAction({ disabled: true, onclick })] }, + }); + await user.click(screen.getByRole('button')); + expect(onclick).not.toHaveBeenCalled(); + }); + }); + + it('fires onclick when clicked', async () => { + const onclick = vi.fn(); + render(ActionButtons, { + props: { actions: [makeAction({ onclick })] }, + }); + await user.click(screen.getByRole('button')); + expect(onclick).toHaveBeenCalledOnce(); + }); + + describe('title attribute', () => { + it.each([ + { title: 'Custom Title', expected: 'Custom Title', desc: 'uses explicit title' }, + { title: undefined, expected: 'Test Action', desc: 'falls back to label' }, + ])('$desc', ({ title, expected }) => { + render(ActionButtons, { + props: { actions: [makeAction({ title })] }, + }); + expect(screen.getByRole('button')).toHaveAttribute('title', expected); + }); + }); + + it('renders N buttons for N actions', () => { + const actions = [makeAction({ label: 'A' }), makeAction({ label: 'B' }), makeAction({ label: 'C' })]; + render(ActionButtons, { props: { actions } }); + expect(screen.getAllByRole('button')).toHaveLength(3); + }); +}); diff --git a/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts b/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts new file mode 100644 index 00000000..0d4f5af5 --- /dev/null +++ b/frontend/src/components/admin/__tests__/AutoRefreshControl.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import AutoRefreshControl from '$components/admin/AutoRefreshControl.svelte'; + +vi.mock('$components/Spinner.svelte', async () => { + const utils = await import('$test/test-utils'); + return { default: utils.createMockNamedComponents({ default: '...' }).default }; +}); + +function renderControl(overrides: Record = {}) { + const onRefresh = vi.fn(); + const onEnabledChange = vi.fn(); + const onRateChange = vi.fn(); + const result = render(AutoRefreshControl, { + props: { + enabled: true, + rate: 5, + loading: false, + onRefresh, + onEnabledChange, + onRateChange, + ...overrides, + }, + }); + return { ...result, onRefresh, onEnabledChange, onRateChange }; +} + +describe('AutoRefreshControl', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders auto-refresh checkbox', () => { + renderControl(); + expect(screen.getByText('Auto-refresh')).toBeInTheDocument(); + }); + + it.each([ + [true, true, true], + [false, false, false], + ] as const)('when enabled=%s: checkbox checked=%s, rate selector visible=%s', (enabled, checked, rateVisible) => { + renderControl({ enabled }); + const checkbox = screen.getByRole('checkbox'); + if (checked) expect(checkbox).toBeChecked(); + else expect(checkbox).not.toBeChecked(); + if (rateVisible) expect(screen.getByLabelText(/Every/)).toBeInTheDocument(); + else expect(screen.queryByLabelText(/Every/)).not.toBeInTheDocument(); + }); + + it.each([ + [false, 'Refresh Now', false], + [true, 'Refreshing...', true], + ] as const)('when loading=%s: shows "%s" button, disabled=%s', (loading, text, disabled) => { + renderControl({ loading }); + const btn = screen.getByText(text).closest('button')!; + expect(btn).toBeInTheDocument(); + if (disabled) expect(btn).toBeDisabled(); + else expect(btn).toBeEnabled(); + }); + + it('calls onRefresh when Refresh Now clicked', async () => { + const { onRefresh } = renderControl(); + await user.click(screen.getByText('Refresh Now')); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it('rate selector has default options', () => { + renderControl({ enabled: true }); + const select = screen.getByLabelText(/Every/) as HTMLSelectElement; + const labels = Array.from(select.options).map(o => o.text); + expect(labels).toEqual(['5 seconds', '10 seconds', '30 seconds', '1 minute']); + }); +}); diff --git a/frontend/src/components/admin/__tests__/FilterPanel.test.ts b/frontend/src/components/admin/__tests__/FilterPanel.test.ts new file mode 100644 index 00000000..ee94a957 --- /dev/null +++ b/frontend/src/components/admin/__tests__/FilterPanel.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import FilterPanelWrapper from './FilterPanelWrapper.svelte'; + +describe('FilterPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('toggle button', () => { + it('renders toggle button by default', () => { + render(FilterPanelWrapper); + expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); + }); + + it('hides toggle button when showToggleButton=false', () => { + render(FilterPanelWrapper, { props: { showToggleButton: false } }); + expect(screen.queryByRole('button', { name: /Filters/i })).not.toBeInTheDocument(); + }); + + it('flips open and fires onToggle on click', async () => { + const onToggle = vi.fn(); + render(FilterPanelWrapper, { props: { onToggle } }); + await user.click(screen.getByRole('button', { name: /Filters/i })); + expect(onToggle).toHaveBeenCalledOnce(); + expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + }); + }); + + describe('active filter badge', () => { + it.each([ + { hasActiveFilters: true, visible: true }, + { hasActiveFilters: false, visible: false }, + ])('badge visible=$visible when hasActiveFilters=$hasActiveFilters', ({ hasActiveFilters, visible }) => { + render(FilterPanelWrapper, { + props: { hasActiveFilters, activeFilterCount: 3 }, + }); + if (visible) { + expect(screen.getByText('3')).toBeInTheDocument(); + } else { + expect(screen.queryByText('3')).not.toBeInTheDocument(); + } + }); + }); + + describe('panel content', () => { + it.each([ + { open: true, visible: true }, + { open: false, visible: false }, + ])('children visible=$visible when open=$open', ({ open, visible }) => { + render(FilterPanelWrapper, { props: { open } }); + if (visible) { + expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + } else { + expect(screen.queryByTestId('filter-content')).not.toBeInTheDocument(); + } + }); + }); + + describe('title', () => { + it.each([ + { title: undefined, expected: 'Filter' }, + { title: 'Advanced', expected: 'Advanced' }, + ])('renders "$expected" when title=$title', ({ title, expected }) => { + render(FilterPanelWrapper, { props: { open: true, title } }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + }); + + describe('action buttons', () => { + it.each([ + { button: 'Clear All', callbackProp: 'onClear' }, + { button: 'Apply', callbackProp: 'onApply' }, + ] as const)('$button: visible only when $callbackProp provided, fires callback', async ({ button, callbackProp }) => { + // Hidden when no callback + render(FilterPanelWrapper, { props: { open: true } }); + expect(screen.queryByRole('button', { name: new RegExp(button, 'i') })).not.toBeInTheDocument(); + + // Visible and fires callback + const callback = vi.fn(); + const { unmount } = render(FilterPanelWrapper, { + props: { open: true, [callbackProp]: callback }, + }); + const btn = screen.getByRole('button', { name: new RegExp(button, 'i') }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(callback).toHaveBeenCalledOnce(); + unmount(); + }); + }); +}); diff --git a/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte b/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte new file mode 100644 index 00000000..7b67a3aa --- /dev/null +++ b/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte @@ -0,0 +1,29 @@ + + + +
Filter slot
+
diff --git a/frontend/src/components/admin/__tests__/StatsCard.test.ts b/frontend/src/components/admin/__tests__/StatsCard.test.ts new file mode 100644 index 00000000..6e4d12d8 --- /dev/null +++ b/frontend/src/components/admin/__tests__/StatsCard.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import type { Component } from 'svelte'; +import StatsCard from '$components/admin/StatsCard.svelte'; + +const MockIcon: Component = (() => ({})) as unknown as Component; + +describe('StatsCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { label: 'Total', value: 42, desc: 'numeric' }, + { label: 'Status', value: 'Active', desc: 'string' }, + ])('renders label and $desc value', ({ label, value }) => { + render(StatsCard, { props: { label, value } }); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText(String(value))).toBeInTheDocument(); + }); + + describe('sublabel', () => { + it('shows sublabel when provided', () => { + render(StatsCard, { props: { label: 'Total', value: 42, sublabel: '+5 today' } }); + expect(screen.getByText('+5 today')).toBeInTheDocument(); + }); + + it('does not render sublabel when absent', () => { + const { container } = render(StatsCard, { props: { label: 'Total', value: 42 } }); + expect(container.querySelectorAll('p')).toHaveLength(2); + }); + }); + + describe('icon', () => { + it.each([ + { icon: MockIcon, pCount: 2, desc: 'renders without error when provided' }, + { icon: undefined, pCount: 2, desc: 'renders only label/value when absent' }, + ])('$desc', ({ icon, pCount }) => { + const { container } = render(StatsCard, { + props: { label: 'Total', value: 42, ...(icon ? { icon } : {}) }, + }); + expect(screen.getByText('Total')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(container.querySelectorAll('p').length).toBeGreaterThanOrEqual(pCount); + }); + }); + + describe('compact mode', () => { + it.each([ + { compact: true, selector: '.p-2' }, + { compact: false, selector: '.p-4' }, + ])('applies $selector padding when compact=$compact', ({ compact, selector }) => { + const { container } = render(StatsCard, { + props: { label: 'Total', value: 42, compact }, + }); + expect(container.querySelector(selector)).toBeInTheDocument(); + }); + }); + + describe('custom colors', () => { + it('applies bgColor class', () => { + const { container } = render(StatsCard, { + props: { label: 'Total', value: 42, bgColor: 'bg-green-100' }, + }); + expect(container.firstElementChild?.classList.contains('bg-green-100')).toBe(true); + }); + + it('applies textColor to value', () => { + render(StatsCard, { + props: { label: 'Total', value: 42, textColor: 'text-red-500' }, + }); + expect(screen.getByText('42').classList.contains('text-red-500')).toBe(true); + }); + }); +}); diff --git a/frontend/src/components/admin/__tests__/StatusBadge.test.ts b/frontend/src/components/admin/__tests__/StatusBadge.test.ts new file mode 100644 index 00000000..c3cd8e9e --- /dev/null +++ b/frontend/src/components/admin/__tests__/StatusBadge.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import StatusBadge from '$components/admin/StatusBadge.svelte'; + +describe('StatusBadge', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders status text', () => { + render(StatusBadge, { props: { status: 'Running' } }); + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + describe('color', () => { + it('defaults to badge-neutral', () => { + render(StatusBadge, { props: { status: 'Test' } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains('badge-neutral')).toBe(true); + }); + + it('applies custom color class', () => { + render(StatusBadge, { props: { status: 'Test', color: 'badge-success' } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains('badge-success')).toBe(true); + }); + }); + + describe('size', () => { + it.each([ + { size: 'sm' as const, expectedClass: 'text-xs' }, + { size: 'md' as const, expectedClass: 'text-sm' }, + { size: 'lg' as const, expectedClass: 'text-base' }, + ])('applies $expectedClass for size=$size', ({ size, expectedClass }) => { + render(StatusBadge, { props: { status: 'Test', size } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains(expectedClass)).toBe(true); + }); + + it('defaults to md (text-sm)', () => { + render(StatusBadge, { props: { status: 'Test' } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains('text-sm')).toBe(true); + }); + }); + + describe('suffix', () => { + it('shows suffix with ml-1 span when provided', () => { + render(StatusBadge, { props: { status: 'Running', suffix: '(3s)' } }); + expect(screen.getByText('(3s)')).toBeInTheDocument(); + const suffixEl = screen.getByText('(3s)'); + expect(suffixEl.classList.contains('ml-1')).toBe(true); + expect(suffixEl.tagName.toLowerCase()).toBe('span'); + }); + + it('does not render suffix when absent', () => { + const { container } = render(StatusBadge, { props: { status: 'Running' } }); + const badge = container.querySelector('.badge'); + expect(badge?.querySelectorAll('span')).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts index 3374b9e1..39bc2d82 100644 --- a/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventDetailsModal.test.ts @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte'; import { createMockEventDetail, user } from '$test/test-utils'; -import EventDetailsModal from '../EventDetailsModal.svelte'; +import EventDetailsModal from '$components/admin/events/EventDetailsModal.svelte'; function renderModal(overrides: Partial<{ event: ReturnType | null; diff --git a/frontend/src/components/admin/events/__tests__/EventFilters.test.ts b/frontend/src/components/admin/events/__tests__/EventFilters.test.ts index 32ad4838..5bdc2295 100644 --- a/frontend/src/components/admin/events/__tests__/EventFilters.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventFilters.test.ts @@ -4,7 +4,7 @@ import { user } from '$test/test-utils'; import { EVENT_TYPES } from '$lib/admin/events/eventTypes'; import type { EventFilter } from '$lib/api'; -import EventFilters from '../EventFilters.svelte'; +import EventFilters from '$components/admin/events/EventFilters.svelte'; function renderFilters(overrides: Partial<{ onApply: () => void; onClear: () => void; filters: EventFilter }> = {}) { const onApply = overrides.onApply ?? vi.fn(); diff --git a/frontend/src/components/admin/events/__tests__/EventStatsCards.test.ts b/frontend/src/components/admin/events/__tests__/EventStatsCards.test.ts index 982ec34e..96c3594c 100644 --- a/frontend/src/components/admin/events/__tests__/EventStatsCards.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventStatsCards.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { createMockStats } from '$test/test-utils'; -import EventStatsCards from '../EventStatsCards.svelte'; +import EventStatsCards from '$components/admin/events/EventStatsCards.svelte'; function renderCards(stats: ReturnType | null, totalEvents = 500) { return render(EventStatsCards, { props: { stats, totalEvents } }); diff --git a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts index fed85a22..63392f39 100644 --- a/frontend/src/components/admin/events/__tests__/EventsTable.test.ts +++ b/frontend/src/components/admin/events/__tests__/EventsTable.test.ts @@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/svelte'; import { createMockEvent, createMockEvents, user } from '$test/test-utils'; -import EventsTable from '../EventsTable.svelte'; +import EventsTable from '$components/admin/events/EventsTable.svelte'; function renderTable(events = createMockEvents(3)) { const onViewDetails = vi.fn(); diff --git a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts index 4efe2b53..9d39acef 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayPreviewModal.test.ts @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -import ReplayPreviewModal from '../ReplayPreviewModal.svelte'; +import ReplayPreviewModal from '$components/admin/events/ReplayPreviewModal.svelte'; interface ReplayPreview { eventId: string; diff --git a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts index 3e1dd0c9..55c55661 100644 --- a/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts +++ b/frontend/src/components/admin/events/__tests__/ReplayProgressBanner.test.ts @@ -4,7 +4,7 @@ import { user } from '$test/test-utils'; import type { EventReplayStatusResponse } from '$lib/api'; -import ReplayProgressBanner from '../ReplayProgressBanner.svelte'; +import ReplayProgressBanner from '$components/admin/events/ReplayProgressBanner.svelte'; function makeSession(overrides: Partial = {}): EventReplayStatusResponse { return { diff --git a/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts b/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts index 32a7315c..d45adb05 100644 --- a/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts +++ b/frontend/src/components/admin/events/__tests__/UserOverviewModal.test.ts @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte'; import { createMockUserOverview } from '$test/test-utils'; -import UserOverviewModal from '../UserOverviewModal.svelte'; +import UserOverviewModal from '$components/admin/events/UserOverviewModal.svelte'; function renderModal(overrides: Partial<{ overview: ReturnType | null; diff --git a/frontend/src/components/admin/sagas/__tests__/SagaDetailsModal.test.ts b/frontend/src/components/admin/sagas/__tests__/SagaDetailsModal.test.ts new file mode 100644 index 00000000..fca0e445 --- /dev/null +++ b/frontend/src/components/admin/sagas/__tests__/SagaDetailsModal.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user, createMockSaga } from '$test/test-utils'; +import SagaDetailsModal from '$components/admin/sagas/SagaDetailsModal.svelte'; + +vi.mock('$lib/formatters', () => ({ + formatTimestamp: vi.fn((ts: string | null) => ts ? `ts:${ts}` : 'N/A'), + formatDurationBetween: vi.fn(() => '1m 30s'), +})); + +vi.mock('$lib/admin/sagas', () => ({ + getSagaStateInfo: vi.fn((state: string) => ({ + label: state.charAt(0).toUpperCase() + state.slice(1), + color: 'badge-neutral', + })), +})); + +function renderModal(overrides: Record = {}) { + const onClose = vi.fn(); + const onViewExecution = vi.fn(); + const result = render(SagaDetailsModal, { + props: { + open: true, + saga: createMockSaga(), + onClose, + onViewExecution, + ...overrides, + }, + }); + return { ...result, onClose, onViewExecution }; +} + +describe('SagaDetailsModal', () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ['open is false', { open: false }, 'Saga Details'], + ['saga is null', { saga: null }, 'Basic Information'], + ] as const)('does not render when %s', (_label, overrides, hiddenText) => { + renderModal(overrides); + expect(screen.queryByText(hiddenText)).not.toBeInTheDocument(); + }); + + it.each(['Saga Details', 'Basic Information', 'Timing Information', 'Execution Steps'])( + 'shows %s section', + (section) => { + renderModal(); + expect(screen.getByText(section)).toBeInTheDocument(); + }, + ); + + it('displays saga ID', () => { + renderModal({ saga: createMockSaga({ saga_id: 'saga-test-123' }) }); + expect(screen.getByText('saga-test-123')).toBeInTheDocument(); + }); + + it('displays saga name', () => { + renderModal({ saga: createMockSaga({ saga_name: 'test_saga' }) }); + expect(screen.getByText('test_saga')).toBeInTheDocument(); + }); + + it('displays execution ID as clickable button', () => { + const saga = createMockSaga({ execution_id: 'exec-999' }); + renderModal({ saga }); + expect(screen.getByText('exec-999')).toBeInTheDocument(); + }); + + it('displays state badge', () => { + renderModal({ saga: createMockSaga({ state: 'completed' }) }); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('displays retry count', () => { + renderModal({ saga: createMockSaga({ retry_count: 5 }) }); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('shows completed steps count', () => { + renderModal({ saga: createMockSaga({ completed_steps: ['step1', 'step2'] }) }); + expect(screen.getByText('Completed (2)')).toBeInTheDocument(); + }); + + it('lists completed steps', () => { + renderModal({ saga: createMockSaga({ completed_steps: ['validate', 'allocate'] }) }); + expect(screen.getByText('validate')).toBeInTheDocument(); + expect(screen.getByText('allocate')).toBeInTheDocument(); + }); + + it.each([ + ['completed', 'No completed steps'], + ['compensated', 'No compensated steps'], + ] as const)('shows "%s" empty state when no steps', (_type, emptyText) => { + renderModal({ saga: createMockSaga({ completed_steps: [], compensated_steps: [] }) }); + expect(screen.getByText(emptyText)).toBeInTheDocument(); + }); + + it('shows compensated steps count', () => { + renderModal({ saga: createMockSaga({ compensated_steps: ['step1'] }) }); + expect(screen.getByText('Compensated (1)')).toBeInTheDocument(); + }); + + it.each([ + ['current step', { current_step: 'create_pod' }, /Current Step:/, 'create_pod'], + ['error message', { error_message: 'Pod creation failed' }, 'Error Information', 'Pod creation failed'], + ] as const)('shows %s when present', (_label, overrides, sectionText, valueText) => { + renderModal({ saga: createMockSaga(overrides) }); + expect(screen.getByText(sectionText)).toBeInTheDocument(); + expect(screen.getByText(valueText)).toBeInTheDocument(); + }); + + it.each([ + ['current step', { current_step: null }, /Current Step:/], + ['error section', { error_message: null }, 'Error Information'], + ] as const)('hides %s when null', (_label, overrides, hiddenText) => { + renderModal({ saga: createMockSaga(overrides) }); + expect(screen.queryByText(hiddenText)).not.toBeInTheDocument(); + }); + + it('calls onViewExecution when execution ID button clicked', async () => { + const saga = createMockSaga({ execution_id: 'exec-click' }); + const { onViewExecution, onClose } = renderModal({ saga }); + await user.click(screen.getByText('exec-click')); + expect(onClose).toHaveBeenCalledOnce(); + expect(onViewExecution).toHaveBeenCalledWith('exec-click'); + }); +}); diff --git a/frontend/src/components/admin/sagas/__tests__/SagaFilters.test.ts b/frontend/src/components/admin/sagas/__tests__/SagaFilters.test.ts new file mode 100644 index 00000000..f2be2d2b --- /dev/null +++ b/frontend/src/components/admin/sagas/__tests__/SagaFilters.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import SagaFilters from '$components/admin/sagas/SagaFilters.svelte'; + +vi.mock('$lib/admin/sagas', () => ({ + SAGA_STATES: { + created: { label: 'Created' }, + running: { label: 'Running' }, + completed: { label: 'Completed' }, + failed: { label: 'Failed' }, + compensating: { label: 'Compensating' }, + timeout: { label: 'Timeout' }, + cancelled: { label: 'Cancelled' }, + }, +})); + +function renderFilters(overrides: Record = {}) { + const onSearch = vi.fn(); + const onClear = vi.fn(); + const result = render(SagaFilters, { + props: { + searchQuery: '', + stateFilter: '', + executionIdFilter: '', + onSearch, + onClear, + ...overrides, + }, + }); + return { ...result, onSearch, onClear }; +} + +describe('SagaFilters', () => { + beforeEach(() => vi.clearAllMocks()); + + it.each(['Search', 'State', 'Execution ID'])('renders %s filter input', (label) => { + renderFilters(); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + }); + + it('renders Clear Filters button', () => { + renderFilters(); + expect(screen.getByText('Clear Filters')).toBeInTheDocument(); + }); + + it('state filter has All States option', () => { + renderFilters(); + const select = screen.getByLabelText('State') as HTMLSelectElement; + expect(select.options[0]!.text).toBe('All States'); + }); + + it('state filter has saga state options', () => { + renderFilters(); + const select = screen.getByLabelText('State') as HTMLSelectElement; + const labels = Array.from(select.options).map(o => o.text); + expect(labels).toContain('Running'); + expect(labels).toContain('Completed'); + expect(labels).toContain('Failed'); + }); + + it('calls onClear when Clear Filters is clicked', async () => { + const { onClear } = renderFilters(); + await user.click(screen.getByText('Clear Filters')); + expect(onClear).toHaveBeenCalledOnce(); + }); + + it('search input has correct placeholder', () => { + renderFilters(); + expect(screen.getByPlaceholderText('Search by ID, name, or error...')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/admin/sagas/__tests__/SagaStatsCards.test.ts b/frontend/src/components/admin/sagas/__tests__/SagaStatsCards.test.ts new file mode 100644 index 00000000..03ca5202 --- /dev/null +++ b/frontend/src/components/admin/sagas/__tests__/SagaStatsCards.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { createMockSaga } from '$test/test-utils'; +import type { SagaStatusResponse } from '$lib/api'; +import SagaStatsCards from '$components/admin/sagas/SagaStatsCards.svelte'; + +const MockIcon = vi.hoisted(() => { + const Comp = function () { return {}; } as unknown as { + new (): object; + render: () => { html: string; css: { code: string; map: null }; head: string }; + }; + Comp.render = () => ({ html: '', css: { code: '', map: null }, head: '' }); + return Comp; +}); + +vi.mock('$lib/admin/sagas', () => ({ + SAGA_STATES: { + created: { label: 'Created', color: 'badge-neutral', bgColor: 'bg-neutral-50', icon: MockIcon }, + running: { label: 'Running', color: 'badge-info', bgColor: 'bg-blue-50', icon: MockIcon }, + compensating: { label: 'Compensating', color: 'badge-warning', bgColor: 'bg-yellow-50', icon: MockIcon }, + completed: { label: 'Completed', color: 'badge-success', bgColor: 'bg-green-50', icon: MockIcon }, + failed: { label: 'Failed', color: 'badge-danger', bgColor: 'bg-red-50', icon: MockIcon }, + timeout: { label: 'Timeout', color: 'badge-warning', bgColor: 'bg-orange-50', icon: MockIcon }, + cancelled: { label: 'Cancelled', color: 'badge-neutral', bgColor: 'bg-neutral-50', icon: MockIcon }, + }, +})); + +function renderCards(sagas: SagaStatusResponse[] = []) { + return render(SagaStatsCards, { props: { sagas } }); +} + +describe('SagaStatsCards', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders a card for each saga state', () => { + renderCards(); + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Compensating')).toBeInTheDocument(); + expect(screen.getByText('Timeout')).toBeInTheDocument(); + expect(screen.getByText('Cancelled')).toBeInTheDocument(); + }); + + it('shows zero counts when no sagas', () => { + renderCards([]); + const zeros = screen.getAllByText('0'); + expect(zeros.length).toBe(7); + }); + + it('shows correct count for each state', () => { + const sagas = [ + createMockSaga({ saga_id: 's1', state: 'running' }), + createMockSaga({ saga_id: 's2', state: 'running' }), + createMockSaga({ saga_id: 's3', state: 'completed' }), + createMockSaga({ saga_id: 's4', state: 'failed' }), + ]; + renderCards(sagas); + expect(screen.getByText('2')).toBeInTheDocument(); + const ones = screen.getAllByText('1'); + expect(ones.length).toBe(2); + const zeros = screen.getAllByText('0'); + expect(zeros.length).toBe(4); + }); + + it('updates counts when sagas change', () => { + const { rerender } = renderCards([createMockSaga({ state: 'running' })]); + expect(screen.getByText('1')).toBeInTheDocument(); + rerender({ + sagas: [ + createMockSaga({ saga_id: 's1', state: 'completed' }), + createMockSaga({ saga_id: 's2', state: 'completed' }), + ], + }); + expect(screen.getByText('2')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/admin/sagas/__tests__/SagasTable.test.ts b/frontend/src/components/admin/sagas/__tests__/SagasTable.test.ts new file mode 100644 index 00000000..17b5544c --- /dev/null +++ b/frontend/src/components/admin/sagas/__tests__/SagasTable.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user, createMockSaga, createMockSagas } from '$test/test-utils'; +import SagasTable from '$components/admin/sagas/SagasTable.svelte'; + +vi.mock('$lib/formatters', () => ({ + formatTimestamp: vi.fn((ts: string) => `ts:${ts}`), + formatDurationBetween: vi.fn(() => '1m 30s'), +})); + +vi.mock('$lib/admin/sagas', () => ({ + getSagaStateInfo: vi.fn((state: string) => ({ + label: state.charAt(0).toUpperCase() + state.slice(1), + color: 'badge-neutral', + bgColor: 'bg-neutral-50', + })), +})); + +vi.mock('$components/Spinner.svelte', async () => { + const utils = await import('$test/test-utils'); + return { default: utils.createMockNamedComponents({ default: '...' }).default }; +}); + +function renderTable(props: Record = {}) { + const onViewDetails = vi.fn(); + const onViewExecution = vi.fn(); + const result = render(SagasTable, { + props: { sagas: [], loading: false, onViewDetails, onViewExecution, ...props }, + }); + return { ...result, onViewDetails, onViewExecution }; +} + +describe('SagasTable', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows loading state when loading with no sagas', () => { + renderTable({ loading: true, sagas: [] }); + expect(screen.getByText('Loading sagas...')).toBeInTheDocument(); + }); + + it('shows empty state when no sagas and not loading', () => { + renderTable({ sagas: [], loading: false }); + expect(screen.getByText('No sagas found')).toBeInTheDocument(); + }); + + it.each(['Saga', 'State', 'Progress', 'Started', 'Duration'])('renders %s table header', (header) => { + renderTable({ sagas: [createMockSaga()] }); + expect(screen.getByText(header)).toBeInTheDocument(); + }); + + it('renders one row per saga in desktop table', () => { + const sagas = createMockSagas(4); + const { container } = renderTable({ sagas }); + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(4); + }); + + it('renders mobile cards for each saga', () => { + const sagas = createMockSagas(2); + const { container } = renderTable({ sagas }); + const mobileCards = container.querySelectorAll('.block.lg\\:hidden > div'); + expect(mobileCards).toHaveLength(2); + }); + + it('displays saga name', () => { + renderTable({ sagas: [createMockSaga({ saga_name: 'my_saga' })] }); + const names = screen.getAllByText('my_saga'); + expect(names.length).toBeGreaterThanOrEqual(1); + }); + + it('displays truncated saga_id', () => { + renderTable({ sagas: [createMockSaga({ saga_id: 'abcdefghijklmno' })] }); + // Desktop shows first 8 chars, mobile shows first 12 chars + const truncated = screen.getAllByText(/ID: abcdefgh/); + expect(truncated.length).toBeGreaterThanOrEqual(1); + }); + + it('displays state badge via getSagaStateInfo', () => { + renderTable({ sagas: [createMockSaga({ state: 'running' })] }); + const badges = screen.getAllByText('Running'); + expect(badges.length).toBeGreaterThanOrEqual(1); + }); + + it('shows retry count when > 0', () => { + renderTable({ sagas: [createMockSaga({ retry_count: 3 })] }); + const retries = screen.getAllByText(/3/); + expect(retries.length).toBeGreaterThanOrEqual(1); + }); + + it('shows completed steps count', () => { + renderTable({ sagas: [createMockSaga({ completed_steps: ['step1', 'step2'] })] }); + const stepsText = screen.getAllByText(/2 steps/); + expect(stepsText.length).toBeGreaterThanOrEqual(1); + }); + + it('shows current step when present', () => { + renderTable({ sagas: [createMockSaga({ current_step: 'create_pod' })] }); + const currentSteps = screen.getAllByText(/create_pod/); + expect(currentSteps.length).toBeGreaterThanOrEqual(1); + }); + + it('calls onViewDetails when View Details clicked', async () => { + const saga = createMockSaga({ saga_id: 'saga-click' }); + const { onViewDetails } = renderTable({ sagas: [saga] }); + const btns = screen.getAllByText('View Details'); + await user.click(btns[0]!); + expect(onViewDetails).toHaveBeenCalledWith('saga-click'); + }); + + it('calls onViewExecution when Execution button clicked in mobile view', async () => { + const saga = createMockSaga({ execution_id: 'exec-click' }); + const { onViewExecution } = renderTable({ sagas: [saga] }); + const btns = screen.getAllByText('Execution'); + await user.click(btns[0]!); + expect(onViewExecution).toHaveBeenCalledWith('exec-click'); + }); +}); diff --git a/frontend/src/components/admin/users/__tests__/DeleteUserModal.test.ts b/frontend/src/components/admin/users/__tests__/DeleteUserModal.test.ts new file mode 100644 index 00000000..3aed2d96 --- /dev/null +++ b/frontend/src/components/admin/users/__tests__/DeleteUserModal.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user, createMockUser } from '$test/test-utils'; +import DeleteUserModal from '$components/admin/users/DeleteUserModal.svelte'; + +vi.mock('$components/Spinner.svelte', async () => { + const utils = await import('$test/test-utils'); + return { default: utils.createMockNamedComponents({ default: '...' }).default }; +}); + +function renderModal(overrides: Record = {}) { + const onClose = vi.fn(); + const onDelete = vi.fn(); + const onCascadeChange = vi.fn(); + const result = render(DeleteUserModal, { + props: { + open: true, + user: createMockUser({ username: 'alice' }), + cascadeDelete: false, + deleting: false, + onClose, + onDelete, + onCascadeChange, + ...overrides, + }, + }); + return { ...result, onClose, onDelete, onCascadeChange }; +} + +describe('DeleteUserModal', () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ['open is false', { open: false }], + ['user is null', { user: null }], + ] as const)('does not render content when %s', (_label, overrides) => { + renderModal(overrides); + expect(screen.queryByText(/Are you sure/)).not.toBeInTheDocument(); + }); + + it('shows username in confirmation message', () => { + renderModal(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to delete user/)).toBeInTheDocument(); + }); + + it('shows cascade delete checkbox', () => { + renderModal(); + expect(screen.getByText(/Delete all user data/)).toBeInTheDocument(); + }); + + it.each([ + [false, false], + [true, true], + ] as const)('cascade warning visibility when cascadeDelete=%s', (cascadeDelete, visible) => { + renderModal({ cascadeDelete }); + if (visible) expect(screen.getByText(/permanently delete all data/)).toBeInTheDocument(); + else expect(screen.queryByText(/permanently delete all data/)).not.toBeInTheDocument(); + }); + + it('shows Delete User button text when not deleting', () => { + renderModal({ deleting: false }); + const deleteBtn = screen.getByRole('button', { name: 'Delete User' }); + expect(deleteBtn).toBeInTheDocument(); + }); + + it('shows Deleting... text when deleting', () => { + renderModal({ deleting: true }); + expect(screen.getByText('Deleting...')).toBeInTheDocument(); + }); + + it('disables buttons when deleting', () => { + renderModal({ deleting: true }); + expect(screen.getByText('Cancel')).toBeDisabled(); + expect(screen.getByText('Deleting...').closest('button')).toBeDisabled(); + }); + + it('calls onDelete when Delete User button clicked', async () => { + const { onDelete } = renderModal(); + await user.click(screen.getByRole('button', { name: 'Delete User' })); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it('calls onClose when Cancel button clicked', async () => { + const { onClose } = renderModal(); + await user.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalledOnce(); + }); + +}); diff --git a/frontend/src/components/admin/users/__tests__/RateLimitsModal.test.ts b/frontend/src/components/admin/users/__tests__/RateLimitsModal.test.ts new file mode 100644 index 00000000..b5248796 --- /dev/null +++ b/frontend/src/components/admin/users/__tests__/RateLimitsModal.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import { proxy } from 'svelte/internal/client'; +import { user, createMockUser } from '$test/test-utils'; +import RateLimitsModal from '$components/admin/users/RateLimitsModal.svelte'; + +vi.mock('$components/Spinner.svelte', async () => { + const utils = await import('$test/test-utils'); + return { default: utils.createMockNamedComponents({ default: '...' }).default }; +}); + +vi.mock('$lib/api', () => ({ + getDefaultRateLimitRulesApiV1AdminRateLimitsDefaultsGet: vi.fn().mockResolvedValue({ + data: [ + { endpoint_pattern: '/api/v1/*', requests: 100, window_seconds: 60, group: 'general', algorithm: 'sliding_window', enabled: true }, + ], + }), +})); + +vi.mock('$lib/admin/rate-limits', () => ({ + getGroupColor: vi.fn(() => 'badge-neutral'), + detectGroupFromEndpoint: vi.fn(() => 'general'), + createEmptyRule: vi.fn(() => ({ + endpoint_pattern: '', + requests: 10, + window_seconds: 60, + group: 'general', + algorithm: 'sliding_window', + enabled: true, + })), +})); + +function renderModal(overrides: Record = {}) { + const onClose = vi.fn(); + const onSave = vi.fn(); + const onReset = vi.fn(); + const result = render(RateLimitsModal, { + props: { + open: true, + user: createMockUser({ username: 'alice' }), + config: proxy({ + user_id: 'user-1', + bypass_rate_limit: false, + global_multiplier: 1.0, + notes: '', + rules: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }), + usage: null, + loading: false, + saving: false, + onClose, + onSave, + onReset, + ...overrides, + }, + }); + return { ...result, onClose, onSave, onReset }; +} + +describe('RateLimitsModal', () => { + beforeEach(() => vi.clearAllMocks()); + + it('does not render content when open is false', () => { + renderModal({ open: false }); + expect(screen.queryByText('Quick Settings')).not.toBeInTheDocument(); + }); + + it('shows spinner when loading', () => { + renderModal({ loading: true, config: null }); + // When config is null, spinner is shown + expect(screen.queryByText('Quick Settings')).not.toBeInTheDocument(); + }); + + it('displays modal title with username', () => { + renderModal(); + expect(screen.getByText('Rate Limits for alice')).toBeInTheDocument(); + }); + + it.each([ + ['Quick Settings', 'text'], + ['Bypass all rate limits', 'text'], + ['Global Multiplier', 'label'], + ['Admin Notes', 'label'], + ['Endpoint Rate Limits', 'text'], + ['Add Rule', 'text'], + ] as const)('shows %s element', (text, queryType) => { + renderModal(); + if (queryType === 'label') expect(screen.getByLabelText(text)).toBeInTheDocument(); + else expect(screen.getByText(text)).toBeInTheDocument(); + }); + + it('shows default rules after mount', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getByText('Default Global Rules')).toBeInTheDocument(); + }); + }); + + it('shows current usage section when usage data provided', () => { + renderModal({ + usage: { '/api/v1/execute': { algorithm: 'sliding_window', remaining: 50 } }, + }); + expect(screen.getByText('Current Usage')).toBeInTheDocument(); + expect(screen.getByText('/api/v1/execute')).toBeInTheDocument(); + expect(screen.getByText('50 remaining')).toBeInTheDocument(); + }); + + it('hides current usage when no usage data', () => { + renderModal({ usage: null }); + expect(screen.queryByText('Current Usage')).not.toBeInTheDocument(); + }); + + it('shows Reset All Counters when usage exists', () => { + renderModal({ + usage: { '/api/v1/test': { algorithm: 'sliding_window', remaining: 10 } }, + }); + expect(screen.getByText('Reset All Counters')).toBeInTheDocument(); + }); + + it('calls onSave when Save Changes clicked', async () => { + const { onSave } = renderModal(); + await user.click(screen.getByText('Save Changes')); + expect(onSave).toHaveBeenCalledOnce(); + }); + + it('calls onClose when Cancel clicked', async () => { + const { onClose } = renderModal(); + await user.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('shows Saving... when saving', () => { + renderModal({ saving: true }); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + it('disables Save button when saving', () => { + renderModal({ saving: true }); + expect(screen.getByText('Saving...').closest('button')).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/admin/users/__tests__/UserFilters.test.ts b/frontend/src/components/admin/users/__tests__/UserFilters.test.ts new file mode 100644 index 00000000..d9c4c97e --- /dev/null +++ b/frontend/src/components/admin/users/__tests__/UserFilters.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { proxy } from 'svelte/internal/client'; +import { user } from '$test/test-utils'; +import UserFilters from '$components/admin/users/UserFilters.svelte'; + +function renderFilters(overrides: Record = {}) { + const onReset = vi.fn(); + const result = render(UserFilters, { + props: { + searchQuery: '', + roleFilter: 'all', + statusFilter: 'all', + advancedFilters: proxy({ bypassRateLimit: 'all' as const, hasCustomLimits: 'all' as const, globalMultiplier: 'all' as const }), + showAdvancedFilters: false, + hasFiltersActive: false, + onReset, + ...overrides, + }, + }); + return { ...result, onReset }; +} + +describe('UserFilters', () => { + beforeEach(() => vi.clearAllMocks()); + + it.each(['Search', 'Role', 'Status'])('renders %s filter input', (label) => { + renderFilters(); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + }); + + it('renders Advanced toggle button', () => { + renderFilters(); + expect(screen.getByText('Advanced')).toBeInTheDocument(); + }); + + it('renders Reset button', () => { + renderFilters(); + expect(screen.getByText('Reset')).toBeInTheDocument(); + }); + + it.each([ + [false, 'disabled'], + [true, 'enabled'], + ] as const)('Reset button is %s when hasFiltersActive=%s', (active, state) => { + renderFilters({ hasFiltersActive: active }); + const btn = screen.getByText('Reset'); + if (state === 'disabled') expect(btn).toBeDisabled(); + else expect(btn).toBeEnabled(); + }); + + it('calls onReset when Reset is clicked', async () => { + const { onReset } = renderFilters({ hasFiltersActive: true }); + await user.click(screen.getByText('Reset')); + expect(onReset).toHaveBeenCalledOnce(); + }); + + it('does not show advanced filters when showAdvancedFilters is false', () => { + renderFilters({ showAdvancedFilters: false }); + expect(screen.queryByText('Rate Limit Filters')).not.toBeInTheDocument(); + }); + + it('shows advanced filter panel when showAdvancedFilters is true', () => { + renderFilters({ showAdvancedFilters: true }); + expect(screen.getByText('Rate Limit Filters')).toBeInTheDocument(); + expect(screen.getByLabelText('Bypass Rate Limit')).toBeInTheDocument(); + expect(screen.getByLabelText('Custom Limits')).toBeInTheDocument(); + expect(screen.getByLabelText('Global Multiplier')).toBeInTheDocument(); + }); + + it.each([ + ['Role', ['all', 'user', 'moderator', 'admin']], + ['Status', ['all', 'active', 'disabled']], + ] as const)('%s filter has correct options', (label, expected) => { + renderFilters(); + const select = screen.getByLabelText(label) as HTMLSelectElement; + const options = Array.from(select.options).map(o => o.value); + expect(options).toEqual(expected); + }); +}); diff --git a/frontend/src/components/admin/users/__tests__/UserFormModal.test.ts b/frontend/src/components/admin/users/__tests__/UserFormModal.test.ts new file mode 100644 index 00000000..4c31e3b7 --- /dev/null +++ b/frontend/src/components/admin/users/__tests__/UserFormModal.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { proxy } from 'svelte/internal/client'; +import { user, createMockUser } from '$test/test-utils'; +import UserFormModal from '$components/admin/users/UserFormModal.svelte'; + +vi.mock('$components/Spinner.svelte', async () => { + const utils = await import('$test/test-utils'); + return { default: utils.createMockNamedComponents({ default: '...' }).default }; +}); + +function renderModal(overrides: Record = {}) { + const onClose = vi.fn(); + const onSave = vi.fn(); + const result = render(UserFormModal, { + props: { + open: true, + editingUser: null, + form: proxy({ username: '', email: '', password: '', role: 'user', is_active: true }), + saving: false, + onClose, + onSave, + ...overrides, + }, + }); + return { ...result, onClose, onSave }; +} + +describe('UserFormModal', () => { + beforeEach(() => vi.clearAllMocks()); + + it('does not render when open is false', () => { + renderModal({ open: false }); + expect(screen.queryByText('Create New User')).not.toBeInTheDocument(); + }); + + it.each([ + ['creating', null, 'Create New User'], + ['editing', 'mock', 'Edit User'], + ] as const)('shows correct title when %s', (_label, editingUser, title) => { + renderModal({ editingUser: editingUser === 'mock' ? createMockUser() : null }); + expect(screen.getByText(title)).toBeInTheDocument(); + }); + + it.each([/Username/, 'Email', /Password/, 'Role'])('renders %s field', (label) => { + renderModal(); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + }); + + it.each([ + ['creating', null, 'Create User', false], + ['editing', 'mock', 'Update User', true], + ] as const)('when %s: button="%s", password hint=%s', (_label, editingUser, buttonText, showsHint) => { + renderModal({ editingUser: editingUser === 'mock' ? createMockUser() : null }); + expect(screen.getByText(buttonText)).toBeInTheDocument(); + if (showsHint) expect(screen.getByText('(leave empty to keep current)')).toBeInTheDocument(); + else expect(screen.queryByText('(leave empty to keep current)')).not.toBeInTheDocument(); + }); + + it('shows Saving... text when saving', () => { + renderModal({ saving: true }); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + it.each(['Cancel', 'Saving...'])('disables %s button when saving', (text) => { + renderModal({ saving: true }); + const el = screen.getByText(text); + const btn = el.closest('button') ?? el; + expect(btn).toBeDisabled(); + }); + + it('calls onClose when Cancel is clicked', async () => { + const { onClose } = renderModal(); + await user.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onSave on form submit', async () => { + const { onSave } = renderModal(); + await user.click(screen.getByText('Create User')); + expect(onSave).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/admin/users/__tests__/UsersTable.test.ts b/frontend/src/components/admin/users/__tests__/UsersTable.test.ts new file mode 100644 index 00000000..bf1d2c5b --- /dev/null +++ b/frontend/src/components/admin/users/__tests__/UsersTable.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user, createMockUser, createMockUsers } from '$test/test-utils'; +import UsersTable from '$components/admin/users/UsersTable.svelte'; + +vi.mock('$lib/formatters', () => ({ + formatTimestamp: vi.fn((ts: string) => `formatted:${ts}`), +})); + +function renderTable(props: Record = {}) { + const onEdit = vi.fn(); + const onRateLimits = vi.fn(); + const onDelete = vi.fn(); + const result = render(UsersTable, { + props: { users: [], loading: false, onEdit, onRateLimits, onDelete, ...props }, + }); + return { ...result, onEdit, onRateLimits, onDelete }; +} + +describe('UsersTable', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows loading text when loading', () => { + renderTable({ loading: true }); + expect(screen.getByText('Loading users...')).toBeInTheDocument(); + }); + + it('shows empty state when no users', () => { + renderTable({ users: [] }); + expect(screen.getByText('No users found matching filters')).toBeInTheDocument(); + }); + + it('does not show loading or empty state when users exist', () => { + renderTable({ users: createMockUsers(2) }); + expect(screen.queryByText('Loading users...')).not.toBeInTheDocument(); + expect(screen.queryByText('No users found matching filters')).not.toBeInTheDocument(); + }); + + it('renders desktop table headers', () => { + renderTable({ users: [createMockUser()] }); + expect(screen.getByText('Username')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Role')).toBeInTheDocument(); + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + }); + + it('renders one row per user in desktop table', () => { + const users = createMockUsers(3); + const { container } = renderTable({ users }); + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + }); + + it('renders mobile cards for each user', () => { + const users = createMockUsers(2); + const { container } = renderTable({ users }); + // Mobile view: block lg:hidden div with cards + const mobileCards = container.querySelectorAll('.block.lg\\:hidden > div'); + expect(mobileCards).toHaveLength(2); + }); + + it('displays username and email', () => { + renderTable({ users: [createMockUser({ username: 'alice', email: 'alice@test.com' })] }); + const aliceTexts = screen.getAllByText('alice'); + expect(aliceTexts.length).toBeGreaterThanOrEqual(1); + const emailTexts = screen.getAllByText('alice@test.com'); + expect(emailTexts.length).toBeGreaterThanOrEqual(1); + }); + + it('displays role badge', () => { + renderTable({ users: [createMockUser({ role: 'admin' })] }); + const badges = screen.getAllByText('admin'); + expect(badges.length).toBeGreaterThanOrEqual(1); + }); + + it.each([ + [true, 'Active'], + [false, 'Inactive'], + ] as const)('displays %s status for is_active=%s', (isActive, label) => { + renderTable({ users: [createMockUser({ is_active: isActive })] }); + const texts = screen.getAllByText(label); + expect(texts.length).toBeGreaterThanOrEqual(1); + }); + + it('displays dash for missing email in desktop table', () => { + renderTable({ users: [createMockUser({ email: '' })] }); + const dashes = screen.getAllByText('-'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('displays "No email" for missing email in mobile card', () => { + renderTable({ users: [createMockUser({ email: '' })] }); + expect(screen.getByText('No email')).toBeInTheDocument(); + }); + + it.each([ + ['Edit User', 'onEdit'], + ['Manage Rate Limits', 'onRateLimits'], + ['Delete User', 'onDelete'], + ] as const)('calls %s callback when button clicked', async (title, callbackKey) => { + const mockUser = createMockUser(); + const result = renderTable({ users: [mockUser] }); + const btns = screen.getAllByTitle(title); + await user.click(btns[0]!); + expect(result[callbackKey]).toHaveBeenCalledWith(mockUser); + }); + + it('formats created_at timestamp', () => { + renderTable({ users: [createMockUser({ created_at: '2024-01-15T10:30:00Z' })] }); + const formatted = screen.getAllByText('formatted:2024-01-15T10:30:00Z'); + expect(formatted.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts b/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts index a4be2c5b..74e1453c 100644 --- a/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts +++ b/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts @@ -71,7 +71,7 @@ vi.mock('@uiw/codemirror-theme-github', () => ({ githubLight: 'githubLight-theme vi.mock('$stores/theme.svelte', () => ({ themeStore: { value: 'light' } })); vi.mock('$lib/editor/languages', () => ({ getLanguageExtension: mocks.getLanguageExtensionFn })); -import CodeMirrorEditor from '../CodeMirrorEditor.svelte'; +import CodeMirrorEditor from '$components/editor/CodeMirrorEditor.svelte'; const defaultSettings: EditorSettingsOutput = { font_size: 14, tab_size: 4, show_line_numbers: true, word_wrap: false, use_tabs: false, diff --git a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts index c96b122f..2e4d819c 100644 --- a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts +++ b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts @@ -3,7 +3,7 @@ import { render, screen, within, fireEvent, waitFor } from '@testing-library/sve import { user } from '$test/test-utils'; -import LanguageSelect from '../LanguageSelect.svelte'; +import LanguageSelect from '$components/editor/LanguageSelect.svelte'; const RUNTIMES = { python: { versions: ['3.11', '3.10', '3.9'], file_ext: 'py' }, @@ -98,6 +98,7 @@ describe('LanguageSelect', () => { it.each([ { key: '{ArrowDown}', label: 'ArrowDown' }, { key: '{Enter}', label: 'Enter' }, + { key: ' ', label: 'Space' }, ])('opens menu with $label on trigger', async ({ key }) => { renderSelect(); screen.getByRole('button', { name: /Select language/i }).focus(); @@ -114,4 +115,105 @@ describe('LanguageSelect', () => { }); }); }); + + describe('menu keyboard navigation', () => { + async function openMenuAndGetMenu() { + const result = renderSelect(); + await user.click(screen.getByRole('button', { name: /Select language/i })); + const menu = screen.getByRole('menu', { name: 'Select language and version' }); + return { ...result, menu }; + } + + it.each([ + { key: 'ArrowDown', presses: 1, expectedIndex: 1, desc: 'moves focus to next language' }, + { key: 'ArrowDown', presses: 3, expectedIndex: 2, desc: 'clamps at last language' }, + { key: 'ArrowUp', presses: 1, expectedIndex: 0, desc: 'clamps at first language' }, + ])('$key $desc', async ({ key, presses, expectedIndex }) => { + const { menu } = await openMenuAndGetMenu(); + for (let i = 0; i < presses; i++) { + await fireEvent.keyDown(menu, { key }); + } + const items = screen.getAllByRole('menuitem'); + expect(items[expectedIndex]).toHaveAttribute('tabindex', '0'); + }); + + it('ArrowUp moves focus to previous language', async () => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowDown' }); // index 1 + await fireEvent.keyDown(menu, { key: 'ArrowUp' }); // back to 0 + const items = screen.getAllByRole('menuitem'); + expect(items[0]).toHaveAttribute('tabindex', '0'); + }); + + it.each([ + { key: 'ArrowRight', label: 'ArrowRight' }, + { key: 'Enter', label: 'Enter' }, + { key: ' ', label: 'Space' }, + ])('$label on language opens version submenu', async ({ key }) => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key }); + expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); + }); + + it.each([ + { key: 'ArrowDown', presses: 1, expectedIndex: 1, desc: 'moves to next version' }, + { key: 'ArrowDown', presses: 3, expectedIndex: 2, desc: 'clamps at last version' }, + { key: 'ArrowUp', presses: 1, expectedIndex: 0, desc: 'clamps at first version' }, + ])('$key in submenu $desc', async ({ key, presses, expectedIndex }) => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); // open python submenu + for (let i = 0; i < presses; i++) { + await fireEvent.keyDown(menu, { key }); + } + const versionMenu = screen.getByRole('menu', { name: /python versions/i }); + const versions = within(versionMenu).getAllByRole('menuitemradio'); + expect(versions[expectedIndex]).toHaveAttribute('tabindex', '0'); + }); + + it('ArrowUp in submenu moves to previous version', async () => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); + await fireEvent.keyDown(menu, { key: 'ArrowDown' }); // index 1 + await fireEvent.keyDown(menu, { key: 'ArrowUp' }); // back to 0 + const versionMenu = screen.getByRole('menu', { name: /python versions/i }); + const versions = within(versionMenu).getAllByRole('menuitemradio'); + expect(versions[0]).toHaveAttribute('tabindex', '0'); + }); + + it.each([ + { key: 'Enter', label: 'Enter' }, + { key: ' ', label: 'Space' }, + ])('$label in submenu selects version and closes menu', async ({ key }) => { + const { onselect, menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); // open python submenu + await fireEvent.keyDown(menu, { key: 'ArrowDown' }); // move to version index 1 (3.10) + await fireEvent.keyDown(menu, { key }); + expect(onselect).toHaveBeenCalledWith('python', '3.10'); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + + it.each([ + { key: 'ArrowLeft', label: 'ArrowLeft' }, + { key: 'Escape', label: 'Escape' }, + ])('$label in submenu exits submenu but keeps main menu', async ({ key }) => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); // open python submenu + expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); + await fireEvent.keyDown(menu, { key }); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: /python versions/i })).not.toBeInTheDocument(); + }); + expect(screen.getByRole('menu', { name: 'Select language and version' })).toBeInTheDocument(); + }); + + it('Escape in root menu closes entire menu', async () => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/src/components/editor/__tests__/OutputPanel.test.ts b/frontend/src/components/editor/__tests__/OutputPanel.test.ts index 8e7d7077..76804349 100644 --- a/frontend/src/components/editor/__tests__/OutputPanel.test.ts +++ b/frontend/src/components/editor/__tests__/OutputPanel.test.ts @@ -5,7 +5,7 @@ import { toast } from 'svelte-sonner'; import type { ExecutionResult } from '$lib/api'; import type { ExecutionPhase } from '$lib/editor'; -import OutputPanel from '../OutputPanel.svelte'; +import OutputPanel from '$components/editor/OutputPanel.svelte'; function makeResult(overrides: Partial = {}): ExecutionResult { return { diff --git a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts index 753cc3a0..1f24a976 100644 --- a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts +++ b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -import ResourceLimits from '../ResourceLimits.svelte'; +import ResourceLimits from '$components/editor/ResourceLimits.svelte'; const LIMITS = { cpu_limit: '500m', diff --git a/frontend/src/components/editor/__tests__/SavedScripts.test.ts b/frontend/src/components/editor/__tests__/SavedScripts.test.ts index 21869006..90bdc10c 100644 --- a/frontend/src/components/editor/__tests__/SavedScripts.test.ts +++ b/frontend/src/components/editor/__tests__/SavedScripts.test.ts @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/svelte'; import { user } from '$test/test-utils'; -import SavedScripts from '../SavedScripts.svelte'; +import SavedScripts from '$components/editor/SavedScripts.svelte'; import type { SavedScriptResponse } from '$lib/api'; function createScripts(count: number): SavedScriptResponse[] { diff --git a/frontend/src/lib/__tests__/api-interceptors.test.ts b/frontend/src/lib/__tests__/api-interceptors.test.ts index 890fcf02..2533caec 100644 --- a/frontend/src/lib/__tests__/api-interceptors.test.ts +++ b/frontend/src/lib/__tests__/api-interceptors.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { getErrorMessage, unwrap, unwrapOr } from '../api-interceptors'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getErrorMessage, unwrap, unwrapOr } from '$lib/api-interceptors'; describe('getErrorMessage', () => { it.each([ @@ -65,3 +65,183 @@ describe('unwrapOr', () => { expect(unwrapOr(result, fallback)).toBe(expected); }); }); + +describe('initializeApiInterceptors', () => { + let mockErrorUse: ReturnType; + let mockRequestUse: ReturnType; + let mockSetConfig: ReturnType; + let mockToast: Record>; + let mockGoto: ReturnType; + let mockAuthStore: { isAuthenticated: boolean; csrfToken: string | null; clearAuth: ReturnType }; + let errorInterceptor: (error: unknown, response: Response | undefined, request: Request, opts: unknown) => unknown; + let requestInterceptor: (request: Request, opts: unknown) => Request; + + beforeEach(async () => { + vi.resetModules(); + + mockErrorUse = vi.fn(); + mockRequestUse = vi.fn(); + mockSetConfig = vi.fn(); + mockToast = { error: vi.fn(), warning: vi.fn() }; + mockGoto = vi.fn(); + mockAuthStore = { isAuthenticated: true, csrfToken: 'csrf-123', clearAuth: vi.fn() }; + + vi.doMock('$lib/api/client.gen', () => ({ + client: { + setConfig: mockSetConfig, + interceptors: { + error: { use: mockErrorUse }, + request: { use: mockRequestUse }, + }, + }, + })); + vi.doMock('svelte-sonner', () => ({ toast: mockToast })); + vi.doMock('@mateothegreat/svelte5-router', () => ({ goto: mockGoto })); + vi.doMock('$stores/auth.svelte', () => ({ authStore: mockAuthStore })); + + const mod = await import('$lib/api-interceptors'); + mod.initializeApiInterceptors(); + + errorInterceptor = mockErrorUse.mock.calls[0]![0]; + requestInterceptor = mockRequestUse.mock.calls[0]![0]; + }); + + it('calls client.setConfig with baseUrl and credentials', () => { + expect(mockSetConfig).toHaveBeenCalledWith({ baseUrl: '', credentials: 'include' }); + }); + + it('registers error interceptor', () => { + expect(mockErrorUse).toHaveBeenCalledOnce(); + }); + + it('registers request interceptor', () => { + expect(mockRequestUse).toHaveBeenCalledOnce(); + }); + + describe('error interceptor: handleErrorStatus', () => { + function callError(status: number | undefined, url = 'https://test/api/v1/execute') { + const response = status ? { status } as Response : undefined; + const request = new Request(url); + vi.spyOn(console, 'error').mockImplementation(() => {}); + return errorInterceptor('some error', response, request, {}); + } + + it('shows network error toast when status undefined', () => { + callError(undefined); + expect(mockToast.error).toHaveBeenCalledWith('Network error. Check your connection.'); + }); + + it('skips network error toast for auth endpoints', () => { + callError(undefined, 'https://test/api/v1/auth/login'); + expect(mockToast.error).not.toHaveBeenCalled(); + }); + + it('handles 401 - clears auth and redirects to /login', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/editor', search: '' }, + writable: true, + configurable: true, + }); + callError(401); + expect(mockAuthStore.clearAuth).toHaveBeenCalled(); + expect(mockToast.warning).toHaveBeenCalledWith('Session expired. Please log in again.'); + expect(mockGoto).toHaveBeenCalledWith('/login'); + }); + + it('stores redirectAfterLogin in sessionStorage for non-login paths', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/editor', search: '?tab=1' }, + writable: true, + configurable: true, + }); + callError(401); + expect(sessionStorage.setItem).toHaveBeenCalledWith('redirectAfterLogin', '/editor?tab=1'); + }); + + it('does not store redirectAfterLogin for /login path', async () => { + // Wait for isHandling401 debounce from prior tests to clear + await vi.advanceTimersByTimeAsync(1500); + vi.mocked(sessionStorage.setItem).mockClear(); + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '' }, + writable: true, + configurable: true, + }); + callError(401); + expect(sessionStorage.setItem).not.toHaveBeenCalledWith('redirectAfterLogin', expect.anything()); + }); + + it('401 on auth endpoint is a no-op', () => { + callError(401, 'https://test/api/v1/auth/login'); + expect(mockAuthStore.clearAuth).not.toHaveBeenCalled(); + expect(mockGoto).not.toHaveBeenCalled(); + }); + + it('401 when not authenticated just clears auth without redirect', () => { + mockAuthStore.isAuthenticated = false; + Object.defineProperty(window, 'location', { + value: { pathname: '/editor', search: '' }, + writable: true, + configurable: true, + }); + callError(401); + expect(mockAuthStore.clearAuth).toHaveBeenCalled(); + expect(mockGoto).not.toHaveBeenCalled(); + }); + + it.each([ + [403, 'error', 'Access denied.'], + [429, 'warning', 'Too many requests. Please slow down.'], + [500, 'error', 'Server error. Please try again later.'], + [503, 'error', 'Server error. Please try again later.'], + ] as [number, 'error' | 'warning', string][])('shows correct toast for %i status', (status, toastType, message) => { + callError(status); + expect(mockToast[toastType]).toHaveBeenCalledWith(message); + }); + + it('shows account locked for 423', () => { + callError(423); + expect(mockToast.warning).toHaveBeenCalledWith(expect.stringContaining('temporarily locked')); + }); + + it('shows validation error for 422', () => { + const response = { status: 422 } as Response; + const request = new Request('https://test/api/v1/execute'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + errorInterceptor({ detail: 'bad field' }, response, request, {}); + expect(mockToast.error).toHaveBeenCalledWith(expect.stringContaining('Validation error')); + }); + + it('shows generic error for unhandled non-auth status', () => { + callError(418); + expect(mockToast.error).toHaveBeenCalledWith(expect.any(String)); + }); + + it('returns the error object', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = errorInterceptor('my-error', { status: 403 } as Response, new Request('https://test/api'), {}); + expect(result).toBe('my-error'); + }); + }); + + describe('CSRF request interceptor', () => { + it.each(['POST', 'PUT', 'DELETE'])('adds X-CSRF-Token header for %s requests', (method) => { + const request = new Request('https://test/api', { method }); + const result = requestInterceptor(request, {}); + expect(result.headers.get('X-CSRF-Token')).toBe('csrf-123'); + }); + + it('skips header for GET requests', () => { + const request = new Request('https://test/api', { method: 'GET' }); + const result = requestInterceptor(request, {}); + expect(result.headers.get('X-CSRF-Token')).toBeNull(); + }); + + it('skips header when no CSRF token available', () => { + mockAuthStore.csrfToken = null; + const request = new Request('https://test/api', { method: 'POST' }); + const result = requestInterceptor(request, {}); + expect(result.headers.get('X-CSRF-Token')).toBeNull(); + }); + }); +}); diff --git a/frontend/src/lib/__tests__/formatters.test.ts b/frontend/src/lib/__tests__/formatters.test.ts index 6f4a25d3..809b2f26 100644 --- a/frontend/src/lib/__tests__/formatters.test.ts +++ b/frontend/src/lib/__tests__/formatters.test.ts @@ -8,7 +8,7 @@ import { formatBytes, formatNumber, truncate, -} from '../formatters'; +} from '$lib/formatters'; describe('formatDate', () => { it.each([ diff --git a/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts b/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts index 1cd6f159..e1b19beb 100644 --- a/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts +++ b/frontend/src/lib/admin/stores/__tests__/eventsStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { effect_root } from 'svelte/internal/client'; +import { createMockEvent, createMockStats } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ browseEventsApiV1AdminEventsBrowsePost: vi.fn(), @@ -73,28 +74,6 @@ class MockEventSource { const { createEventsStore } = await import('../eventsStore.svelte'); -const createMockEvent = (overrides: Record = {}) => ({ - event_id: 'evt-1', - event_type: 'execution_completed', - event_version: '1', - timestamp: '2024-01-15T10:30:00Z', - aggregate_id: 'exec-456', - metadata: { service_name: 'test-service', service_version: '1.0.0', user_id: 'user-1' }, - execution_id: 'exec-456', - exit_code: 0, - stdout: 'hello', - ...overrides, -}); - -const createMockStats = () => ({ - total_events: 150, - error_rate: 2.5, - avg_processing_time: 1.23, - top_users: [], - events_by_type: [], - events_by_hour: [], -}); - describe('EventsStore', () => { let store: ReturnType; let teardown: () => void; diff --git a/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts b/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts index dde36a3f..392aee23 100644 --- a/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts +++ b/frontend/src/lib/admin/stores/__tests__/executionsStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { effect_root } from 'svelte/internal/client'; +import { createMockExecution, createMockQueueStatus } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ listExecutionsApiV1AdminExecutionsGet: vi.fn(), @@ -32,26 +33,17 @@ vi.mock('svelte-sonner', () => ({ const { createExecutionsStore } = await import('../executionsStore.svelte'); -const createMockExecution = (overrides: Record = {}) => ({ - execution_id: 'exec-1', - script: 'print("hi")', - status: 'queued', - lang: 'python', - lang_version: '3.11', - priority: 'normal', - user_id: 'user-1', - created_at: '2024-01-15T10:30:00Z', - updated_at: null, - ...overrides, -}); - -const createMockQueueStatus = (overrides: Record = {}) => ({ - queue_depth: 5, - active_count: 2, - max_concurrent: 10, - by_priority: { normal: 3, high: 2 }, - ...overrides, -}); +function setupDefaultMocks() { + mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ + data: { executions: [], total: 0, limit: 20, skip: 0, has_more: false }, + }); + mocks.getQueueStatusApiV1AdminExecutionsQueueGet.mockResolvedValue({ + data: createMockQueueStatus(), + }); + mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut.mockResolvedValue({ + data: null, + }); +} describe('ExecutionsStore', () => { let store: ReturnType; @@ -59,26 +51,38 @@ describe('ExecutionsStore', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ - data: { executions: [], total: 0, limit: 20, skip: 0, has_more: false }, - }); - mocks.getQueueStatusApiV1AdminExecutionsQueueGet.mockResolvedValue({ - data: createMockQueueStatus(), - }); - mocks.updatePriorityApiV1AdminExecutionsExecutionIdPriorityPut.mockResolvedValue({ - data: null, - }); + setupDefaultMocks(); }); + /** + * Creates a store with auto-refresh immediately disabled. + * The $effect in createAutoRefresh fires synchronously inside effect_root, + * starting a setInterval. With shouldAdvanceTime: true, that interval can + * auto-fire during any await, contaminating test state. We immediately + * disable it, clear all timers, reset mocks, and re-apply defaults. + */ function createStore() { teardown = effect_root(() => { store = createExecutionsStore(); }); + store.autoRefresh.enabled = false; + store.autoRefresh.cleanup(); + vi.clearAllTimers(); + vi.clearAllMocks(); + setupDefaultMocks(); + } + + function createStoreWithAutoRefresh() { + teardown = effect_root(() => { + store = createExecutionsStore(); + }); } afterEach(() => { + store?.autoRefresh.stop(); store?.cleanup(); teardown?.(); + vi.clearAllTimers(); }); describe('initial state', () => { @@ -100,6 +104,7 @@ describe('ExecutionsStore', () => { describe('loadData', () => { it('loads executions and queue status', async () => { + createStore(); const execs = [createMockExecution()]; mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ data: { executions: execs, total: 1 }, @@ -108,7 +113,6 @@ describe('ExecutionsStore', () => { data: createMockQueueStatus(), }); - createStore(); await store.loadData(); expect(store.executions).toEqual(execs); @@ -117,9 +121,9 @@ describe('ExecutionsStore', () => { }); it('handles empty API response', async () => { + createStore(); mocks.listExecutionsApiV1AdminExecutionsGet.mockResolvedValue({ data: null }); - createStore(); await store.loadExecutions(); expect(store.executions).toEqual([]); @@ -210,8 +214,9 @@ describe('ExecutionsStore', () => { describe('auto-refresh', () => { it('fires loadData on interval', async () => { - createStore(); + createStoreWithAutoRefresh(); vi.clearAllMocks(); + setupDefaultMocks(); await vi.advanceTimersByTimeAsync(5000); expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalledTimes(1); @@ -221,8 +226,9 @@ describe('ExecutionsStore', () => { }); it('stops on cleanup', async () => { - createStore(); - // Verify interval is running + createStoreWithAutoRefresh(); + setupDefaultMocks(); + await vi.advanceTimersByTimeAsync(5000); expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled(); diff --git a/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts b/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts index 52212811..3454512d 100644 --- a/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts +++ b/frontend/src/lib/admin/stores/__tests__/sagasStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { effect_root } from 'svelte/internal/client'; +import { createMockSaga } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ listSagasApiV1SagasGet: vi.fn(), @@ -16,23 +17,6 @@ vi.mock('$lib/api-interceptors', () => ({ const { createSagasStore } = await import('../sagasStore.svelte'); -const createMockSaga = (overrides: Record = {}) => ({ - saga_id: 'saga-1', - saga_name: 'execution_saga', - execution_id: 'exec-123', - state: 'running', - current_step: 'create_pod', - completed_steps: ['validate_execution'], - compensated_steps: [], - retry_count: 0, - error_message: null, - context_data: {}, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:31:00Z', - completed_at: null, - ...overrides, -}); - describe('SagasStore', () => { let store: ReturnType; let teardown: () => void; diff --git a/frontend/src/routes/__tests__/Editor.test.ts b/frontend/src/routes/__tests__/Editor.test.ts index 5475d12e..e8ee16de 100644 --- a/frontend/src/routes/__tests__/Editor.test.ts +++ b/frontend/src/routes/__tests__/Editor.test.ts @@ -324,4 +324,125 @@ describe('Editor', () => { expect(toast.info).toHaveBeenCalledWith('New script started.'); }); }); + + describe('File upload', () => { + function createFileInput() { + const { container } = renderEditor(); + return container.querySelector('input[type="file"]') as HTMLInputElement; + } + + function fireFileChange(input: HTMLInputElement, file: File) { + Object.defineProperty(input, 'files', { value: [file], writable: true }); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + + it('rejects file > 1MB with error toast', async () => { + const input = createFileInput(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + const bigFile = new File(['x'.repeat(1024 * 1024 + 1)], 'large.py', { type: 'text/plain' }); + fireFileChange(input, bigFile); + expect(toast.error).toHaveBeenCalledWith('File too large. Maximum size is 1MB.'); + }); + + it('rejects unsupported file extension with error toast', async () => { + const input = createFileInput(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + const badFile = new File(['content'], 'data.csv', { type: 'text/csv' }); + fireFileChange(input, badFile); + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Unsupported file type')); + }); + + it('loads .py file content and shows info toast', async () => { + const input = createFileInput(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + const pyFile = new File(['print("loaded")'], 'test_script.py', { type: 'text/plain' }); + fireFileChange(input, pyFile); + await waitFor(() => { + expect(toast.info).toHaveBeenCalledWith(expect.stringContaining('Loaded python script from test_script.py')); + }); + }); + }); + + describe('Example script loading', () => { + it('loads example for selected language', async () => { + await renderEditor(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + const exampleBtn = screen.getByTitle('Load an example script for the selected language'); + await user.click(exampleBtn); + expect(toast.info).toHaveBeenCalledWith('Loaded example script for python.'); + expect(mocks.mockExecutionState.reset).toHaveBeenCalled(); + }); + + it('shows warning when no example available', async () => { + mocks.getExampleScriptsApiV1ExampleScriptsGet.mockResolvedValue({ + data: { scripts: {} }, + error: undefined, + }); + await renderEditor(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + const exampleBtn = screen.getByTitle('Load an example script for the selected language'); + await user.click(exampleBtn); + expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining('No example script available')); + }); + }); + + describe('Export script', () => { + it('creates download with correct filename and extension', async () => { + await renderEditor(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + + // Spy on anchor creation without replacing createElement entirely + const mockClick = vi.fn(); + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = origCreateElement(tag); + if (tag === 'a') { + el.click = mockClick; + } + return el; + }); + const mockCreateObjectURL = vi.fn(() => 'blob:mock-url'); + const mockRevokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { createObjectURL: mockCreateObjectURL, revokeObjectURL: mockRevokeObjectURL }); + + await user.type(screen.getByLabelText('Script Name'), 'my_script'); + await user.click(screen.getByRole('button', { name: 'Toggle Script Options' })); + await user.click(screen.getByTitle('Download current script')); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(mockClick).toHaveBeenCalled(); + expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + + vi.mocked(document.createElement).mockRestore(); + }); + }); + + describe('localStorage persistence', () => { + it('saves script name to localStorage after changes', async () => { + await renderEditor(); + await waitFor(() => { + expect(mocks.getK8sResourceLimitsApiV1K8sLimitsGet).toHaveBeenCalled(); + }); + + await user.type(screen.getByLabelText('Script Name'), 'Persisted'); + + // Wait for debounced localStorage write (300ms) + await vi.advanceTimersByTimeAsync(400); + + const stored = localStorage.getItem('scriptName'); + expect(stored).toContain('Persisted'); + }); + }); }); diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts index c403d4d1..08aba90e 100644 --- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts @@ -63,7 +63,6 @@ async function renderWithEvents(events = createMockEvents(5), stats = createMock mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ data: stats, error: null }); const result = render(AdminEvents); - await tick(); await waitFor(() => expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled()); return result; } diff --git a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts index 7b77381e..70966536 100644 --- a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts @@ -1,58 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import { tick } from 'svelte'; -import { user } from '$test/test-utils'; - -interface MockExecutionOverrides { - execution_id?: string; - script?: string; - status?: string; - lang?: string; - lang_version?: string; - priority?: string; - user_id?: string | null; - created_at?: string | null; - updated_at?: string | null; -} - -const DEFAULT_EXECUTION = { - execution_id: 'exec-1', - script: 'print("hi")', - status: 'queued', - lang: 'python', - lang_version: '3.11', - priority: 'normal', - user_id: 'user-1', - created_at: '2024-01-15T10:30:00Z', - updated_at: null as string | null, -}; - -const STATUSES = ['queued', 'scheduled', 'running', 'completed', 'failed', 'timeout', 'cancelled', 'error']; -const PRIORITIES = ['critical', 'high', 'normal', 'low', 'background']; - -const createMockExecution = (overrides: MockExecutionOverrides = {}) => ({ ...DEFAULT_EXECUTION, ...overrides }); - -const createMockExecutions = (count: number) => - Array.from({ length: count }, (_, i) => createMockExecution({ - execution_id: `exec-${i + 1}`, - status: STATUSES[i % STATUSES.length], - priority: PRIORITIES[i % PRIORITIES.length], - user_id: `user-${(i % 3) + 1}`, - created_at: new Date(Date.now() - i * 60000).toISOString(), - })); - -const createMockQueueStatus = (overrides: Partial<{ - queue_depth: number; - active_count: number; - max_concurrent: number; - by_priority: Record; -}> = {}) => ({ - queue_depth: 5, - active_count: 2, - max_concurrent: 10, - by_priority: { normal: 3, high: 2 }, - ...overrides, -}); +import { user, createMockExecution, createMockExecutions, createMockQueueStatus } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ listExecutionsApiV1AdminExecutionsGet: vi.fn(), @@ -102,7 +50,6 @@ async function renderWithExecutions( }); const result = render(AdminExecutions); - await tick(); await waitFor(() => expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled()); return result; } @@ -128,7 +75,6 @@ describe('AdminExecutions', () => { describe('initial loading', () => { it('calls API on mount', async () => { render(AdminExecutions); - await tick(); await waitFor(() => { expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled(); expect(mocks.getQueueStatusApiV1AdminExecutionsQueueGet).toHaveBeenCalled(); @@ -340,7 +286,6 @@ describe('AdminExecutions', () => { }); render(AdminExecutions); - await tick(); await waitFor(() => expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled()); expect(screen.getByText(/showing/i)).toBeInTheDocument(); diff --git a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts index c281b141..fc679bfc 100644 --- a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts @@ -1,52 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { tick } from 'svelte'; -import { user } from '$test/test-utils'; - -interface MockSagaOverrides { - saga_id?: string; - saga_name?: string; - execution_id?: string; - state?: string; - current_step?: string; - completed_steps?: string[]; - compensated_steps?: string[]; - retry_count?: number; - error_message?: string | null; - context_data?: Record; - created_at?: string; - updated_at?: string; - completed_at?: string | null; -} - -const DEFAULT_SAGA = { - saga_id: 'saga-1', - saga_name: 'execution_saga', - execution_id: 'exec-123', - state: 'running', - current_step: 'create_pod', - completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'], - compensated_steps: [] as string[], - retry_count: 0, - error_message: null as string | null, - context_data: { key: 'value' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:31:00Z', - completed_at: null as string | null, -}; - -const SAGA_STATES = ['created', 'running', 'completed', 'failed', 'compensating', 'timeout']; - -const createMockSaga = (overrides: MockSagaOverrides = {}) => ({ ...DEFAULT_SAGA, ...overrides }); - -const createMockSagas = (count: number) => - Array.from({ length: count }, (_, i) => createMockSaga({ - saga_id: `saga-${i + 1}`, - execution_id: `exec-${i + 1}`, - state: SAGA_STATES[i % SAGA_STATES.length], - created_at: new Date(Date.now() - i * 60000).toISOString(), - updated_at: new Date(Date.now() - i * 30000).toISOString(), - })); +import { user, createMockSaga, createMockSagas } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ listSagasApiV1SagasGet: vi.fn(), @@ -73,7 +28,6 @@ async function renderWithSagas(sagas = createMockSagas(5)) { }); const result = render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); return result; } @@ -89,7 +43,6 @@ describe('AdminSagas', () => { describe('initial loading', () => { it('calls listSagas on mount', async () => { render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); }); @@ -380,7 +333,6 @@ describe('AdminSagas', () => { }); render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); expect(screen.getByText(/showing/i)).toBeInTheDocument(); @@ -394,7 +346,6 @@ describe('AdminSagas', () => { }); render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); vi.clearAllMocks(); diff --git a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts index d5d0ea1a..81caf103 100644 --- a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts @@ -1,45 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/svelte'; -import { tick } from 'svelte'; -import { user } from '$test/test-utils'; - -interface MockUserOverrides { - user_id?: string; - username?: string; - email?: string | null; - role?: string; - is_active?: boolean; - is_disabled?: boolean; - created_at?: string; - bypass_rate_limit?: boolean; - has_custom_limits?: boolean; - global_multiplier?: number; -} - -const DEFAULT_USER = { - user_id: 'user-1', - username: 'testuser', - email: 'test@example.com', - role: 'user', - is_active: true, - is_disabled: false, - created_at: '2024-01-15T10:30:00Z', - bypass_rate_limit: false, - has_custom_limits: false, - global_multiplier: 1.0, -}; - -const createMockUser = (overrides: MockUserOverrides = {}) => ({ ...DEFAULT_USER, ...overrides }); - -const createMockUsers = (count: number) => - Array.from({ length: count }, (_, i) => createMockUser({ - user_id: `user-${i + 1}`, - username: `user${i + 1}`, - email: `user${i + 1}@example.com`, - role: i === 0 ? 'admin' : 'user', - is_active: i % 3 !== 0, - is_disabled: i % 3 === 0, - })); +import { user, createMockUser, createMockUsers } from '$test/test-utils'; const mocks = vi.hoisted(() => ({ listUsersApiV1AdminUsersGet: vi.fn(), @@ -93,7 +54,6 @@ import AdminUsers from '$routes/admin/AdminUsers.svelte'; async function renderWithUsers(users = createMockUsers(3)) { mocks.listUsersApiV1AdminUsersGet.mockResolvedValue({ data: { users, total: users.length }, error: null }); const result = render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); return result; } @@ -150,7 +110,7 @@ describe('AdminUsers', () => { }); it('shows dash for missing email', async () => { - const users = [createMockUser({ email: null })]; + const users = [createMockUser({ email: null as unknown as string })]; await renderWithUsers(users); // Desktop table shows '-' for missing email expect(screen.getByText('-')).toBeInTheDocument(); @@ -179,7 +139,6 @@ describe('AdminUsers', () => { return { data: { users: filtered, total: filtered.length }, error: null }; }); render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); const searchInput = screen.getByPlaceholderText(/Search by username/i); @@ -201,7 +160,6 @@ describe('AdminUsers', () => { return { data: { users: filtered, total: filtered.length }, error: null }; }); render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); const roleSelect = screen.getByRole('combobox', { name: /Role/i }); @@ -214,8 +172,8 @@ describe('AdminUsers', () => { it('filters users by status', async () => { const users = [ - createMockUser({ username: 'activeuser', is_active: true, is_disabled: false }), - createMockUser({ user_id: 'u2', username: 'disableduser', is_active: false, is_disabled: true }), + createMockUser({ username: 'activeuser', is_active: true }), + createMockUser({ user_id: 'u2', username: 'disableduser', is_active: false }), ]; await renderWithUsers(users); const statusSelect = screen.getByRole('combobox', { name: /Status/i }); @@ -236,7 +194,6 @@ describe('AdminUsers', () => { return { data: { users, total: users.length }, error: null }; }); render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); const searchInput = screen.getByPlaceholderText(/Search by username/i); @@ -635,8 +592,8 @@ describe('AdminUsers', () => { it('shows filtered count when filters applied', async () => { const users = [ - createMockUser({ username: 'activeuser', is_active: true, is_disabled: false }), - createMockUser({ user_id: 'u2', username: 'disableduser', is_active: false, is_disabled: true }), + createMockUser({ username: 'activeuser', is_active: true }), + createMockUser({ user_id: 'u2', username: 'disableduser', is_active: false }), ]; await renderWithUsers(users); diff --git a/frontend/src/stores/__tests__/notificationStore.test.ts b/frontend/src/stores/__tests__/notificationStore.test.ts index f6dbe664..17cbf9a0 100644 --- a/frontend/src/stores/__tests__/notificationStore.test.ts +++ b/frontend/src/stores/__tests__/notificationStore.test.ts @@ -1,18 +1,19 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { createMockNotification } from '$test/test-utils'; const mockGetNotifications = vi.fn(); const mockMarkRead = vi.fn(); const mockMarkAllRead = vi.fn(); const mockDeleteNotification = vi.fn(); -vi.mock('../../lib/api', () => ({ +vi.mock('$lib/api', () => ({ getNotificationsApiV1NotificationsGet: (...args: unknown[]) => mockGetNotifications(...args), markNotificationReadApiV1NotificationsNotificationIdReadPut: (...args: unknown[]) => mockMarkRead(...args), markAllReadApiV1NotificationsMarkAllReadPost: (...args: unknown[]) => mockMarkAllRead(...args), deleteNotificationApiV1NotificationsNotificationIdDelete: (...args: unknown[]) => mockDeleteNotification(...args), })); -vi.mock('../../lib/api-interceptors', () => ({ +vi.mock('$lib/api-interceptors', () => ({ getErrorMessage: (err: unknown, fallback = 'An error occurred') => { if (!err) return fallback; if (typeof err === 'string') return err; @@ -34,20 +35,6 @@ vi.mock('../../lib/api-interceptors', () => ({ }, })); -const createMockNotification = (overrides: Record = {}) => ({ - notification_id: `notif-${Math.random().toString(36).slice(2)}`, - channel: 'in_app' as const, - status: 'pending' as const, - subject: 'Test Notification', - body: 'Test message body', - action_url: '', - created_at: new Date().toISOString(), - read_at: null, - severity: 'medium' as const, - tags: [] as string[], - ...overrides, -}); - describe('notificationStore', () => { beforeEach(async () => { mockGetNotifications.mockReset(); @@ -258,7 +245,7 @@ describe('notificationStore', () => { it('returns false on failure', async () => { mockGetNotifications.mockResolvedValue({ - data: { notifications: [createMockNotification({ notification_id: 'n1' })] }, + data: { notifications: [createMockNotification({ notification_id: 'n1', status: 'pending' })] }, error: null, }); mockMarkRead.mockResolvedValue({ error: { detail: 'Failed' } }); diff --git a/frontend/src/svelte-internal-client.d.ts b/frontend/src/svelte-internal-client.d.ts index 4a846b94..dd6b2f3c 100644 --- a/frontend/src/svelte-internal-client.d.ts +++ b/frontend/src/svelte-internal-client.d.ts @@ -1,3 +1,4 @@ declare module 'svelte/internal/client' { export function effect_root(fn: () => void): () => void; + export function proxy(value: T): T; } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 656c4413..457ab66f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -10,24 +10,16 @@ export default defineConfig({ svelteTesting(), ], test: { - environment: 'jsdom', pool: 'threads', maxWorkers: 8, minWorkers: 2, isolate: false, css: false, + testTimeout: 10_000, + environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.{test,spec}.{js,ts}'], - globals: true, - testTimeout: 10_000, - fakeTimers: { - shouldAdvanceTime: true, - }, - environmentMatchGlobs: [ - ['src/lib/**/*.test.ts', 'node'], - ['src/stores/**/*.test.ts', 'node'], - ['src/utils/**/*.test.ts', 'node'], - ], + fakeTimers: { shouldAdvanceTime: true }, deps: { optimizer: { web: { diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 8d4982fa..c63ef93b 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -2,6 +2,8 @@ import '@testing-library/jest-dom/vitest'; import { vi, beforeEach } from 'vitest'; import { cleanup } from '@testing-library/svelte'; +vi.useFakeTimers(); + // Global handler for promise rejections (mirrors main.ts behavior) // API errors are handled by interceptor - just silence the rejection in tests process.on('unhandledRejection', () => {}); @@ -72,26 +74,8 @@ vi.stubGlobal('IntersectionObserver', vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), }))); -// Enable fake timers globally (shouldAdvanceTime configured in vitest.config.ts) -vi.useFakeTimers(); - -// Reset DOM and storage between every test (required for isolate: false) -beforeEach(() => { - cleanup(); - Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); - Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); -}); - -// Helper to reset mocks between tests (legacy export, kept for compatibility) -export function resetStorageMocks() { - Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); - Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); - vi.clearAllMocks(); -} - // Mock Element.prototype.animate for Svelte transitions (canonical global stub) -// Guarded for node environment where Element is not available -if (typeof Element !== 'undefined') Element.prototype.animate = vi.fn().mockImplementation(() => { +Element.prototype.animate = vi.fn().mockImplementation(() => { const mock = { _onfinish: null as (() => void) | null, get onfinish() { return this._onfinish; }, @@ -113,3 +97,10 @@ if (typeof Element !== 'undefined') Element.prototype.animate = vi.fn().mockImpl }; return mock as unknown as Animation; }); + +// Reset storage and DOM between every test (required for isolate: false) +beforeEach(() => { + Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); + Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); + cleanup(); +});