/** * Component: Admin Jobs Page Tests * Documentation: documentation/backend/services/scheduler.md */ // @vitest-environment jsdom import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import AdminJobsPage from '@/app/admin/jobs/page'; const authenticatedFetcherMock = vi.hoisted(() => vi.fn()); const fetchJSONMock = vi.hoisted(() => vi.fn()); const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), })); vi.mock('@/lib/utils/api', () => ({ authenticatedFetcher: authenticatedFetcherMock, fetchJSON: fetchJSONMock, })); vi.mock('@/components/ui/Toast', () => ({ ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}, useToast: () => toastMock, })); describe('AdminJobsPage', () => { beforeEach(() => { authenticatedFetcherMock.mockReset(); fetchJSONMock.mockReset(); toastMock.success.mockReset(); toastMock.error.mockReset(); }); it('renders scheduled jobs and allows manual trigger', async () => { authenticatedFetcherMock.mockResolvedValue({ jobs: [ { id: 'job-1', name: 'Library Scan', type: 'plex_library_scan', schedule: '0 * * * *', enabled: true, lastRun: null, nextRun: null, }, ], }); fetchJSONMock.mockResolvedValue({ success: true }); render(); expect((await screen.findAllByText('Library Scan'))[0]).toBeInTheDocument(); expect( (await screen.findAllByText('Scans your full media library to detect newly added audiobooks.'))[0] ).toBeInTheDocument(); expect(screen.queryByText('plex_library_scan')).not.toBeInTheDocument(); expect(screen.queryByText('About Scheduled Jobs')).not.toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: /Trigger Now/i })[0]); fireEvent.click(screen.getByRole('button', { name: 'Trigger Job' })); await waitFor(() => { expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/jobs/job-1/trigger', { method: 'POST', }); expect(toastMock.success).toHaveBeenCalledWith('Job "Library Scan" triggered successfully'); }); expect(authenticatedFetcherMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); it('updates a job schedule using preset selection', async () => { authenticatedFetcherMock.mockResolvedValue({ jobs: [ { id: 'job-2', name: 'Audible Refresh', type: 'audible_refresh', schedule: '0 * * * *', enabled: true, lastRun: null, nextRun: null, }, ], }); fetchJSONMock.mockResolvedValue({ success: true }); render(); fireEvent.click((await screen.findAllByRole('button', { name: 'Edit' }))[0]); fireEvent.click(screen.getByRole('radio', { name: /Every 2 hours/i })); fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); await waitFor(() => { expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/jobs/job-2', { method: 'PUT', body: JSON.stringify({ schedule: '0 */2 * * *', enabled: true }), }); expect(toastMock.success).toHaveBeenCalledWith('Job "Audible Refresh" updated successfully'); }); }); it('shows an error when jobs fail to load', async () => { authenticatedFetcherMock.mockRejectedValue(new Error('boom')); render(); expect(await screen.findByText('Failed to load scheduled jobs')).toBeInTheDocument(); }); });