import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import EditTaskPage from '@/pages/EditTaskPage' import * as entitiesApi from '@/api/entitiesApi' import * as tasksApi from '@/api/tasksApi' type RenderPageOptions = { task?: typeof mockTask } const mockNavigate = vi.fn() vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') return { ...actual, useNavigate: () => mockNavigate } }) vi.mock('@/api/entitiesApi') vi.mock('@/api/tasksApi') function renderPage(options: RenderPageOptions = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const wrapper = ({ children }: { children: React.ReactNode }) => ( ) vi.mocked(tasksApi.getTask).mockResolvedValue(options.task ?? mockTask) render(, { wrapper }) return { queryClient } } const mockEntity = { id: 'entity-1', name: 'Entity A', email: 'a@a.com', jobTitle: 'Ops', personality: 'Formal', scheduleCron: '0 9 * * 1', contextWindowDays: 3, active: true, createdAt: '', } const mockTask = { id: 'task-1', entityId: 'entity-1', name: 'Weekly Check-in', prompt: 'Summarize jokes', scheduleCron: '0 9 * * 1', emailLookback: 'last_week' as const, generationSource: 'openai' as const, active: true, createdAt: '2026-03-26T10:00:00Z', } let persistedHistory: Array<{ id: string taskId: string label: string content: string createdAt: string }> = [] describe('EditTaskPage', () => { beforeEach(() => { vi.clearAllMocks() persistedHistory = [] vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation( (entity, task) => `PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback} | ${task.generationSource}` ) vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true }) vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false }) vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined) vi.mocked(tasksApi.getTaskGeneratedMessages).mockImplementation(async () => persistedHistory) vi.mocked(tasksApi.deleteTaskGeneratedMessage).mockImplementation(async (_taskId, messageId) => { persistedHistory = persistedHistory.filter((message) => message.id !== messageId) }) mockNavigate.mockClear() }) it('should_renderPrefilledTaskForm_when_pageLoads', async () => { renderPage() await screen.findByRole('link', { name: /back to entity a/i }) expect(screen.getByRole('heading', { name: /edit task/i })).toBeInTheDocument() expect(screen.getByLabelText(/task name/i)).toHaveValue('Weekly Check-in') expect(screen.getByLabelText(/task prompt/i)).toHaveValue('Summarize jokes') expect(screen.getByLabelText(/^Generation Source$/i)).toHaveValue('openai') expect(screen.getByLabelText(/^Email Period$/i)).toHaveValue('last_week') expect(screen.getByLabelText(/^Minute$/i)).toHaveValue('0') expect(screen.getByLabelText(/^Hour$/i)).toHaveValue('9') expect(screen.getByLabelText(/^Day of Month$/i)).toHaveValue('*') expect(screen.getByLabelText(/^Month$/i)).toHaveValue('*') expect(screen.getByLabelText(/^Day of Week$/i)).toHaveValue('1') }) it('should_generatePreviewAndUpdateTask_when_formSubmitted', async () => { vi.mocked(tasksApi.generateTaskPreview).mockImplementation(async () => { const content = 'SUBJECT: Preview\nBODY:\nFormal nonsense' const nextMessage = { id: `message-${persistedHistory.length + 1}`, taskId: 'task-1', label: `Message #${persistedHistory.length + 1}`, content, createdAt: '2026-03-27T12:00:00Z', } persistedHistory = [nextMessage, ...persistedHistory] return content }) vi.mocked(tasksApi.updateTask).mockResolvedValue({ ...mockTask, name: 'Daily Check-in', prompt: 'Ask about ceremonial coffee', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_day', generationSource: 'llama', }) const { queryClient } = renderPage() const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') await screen.findByRole('link', { name: /back to entity a/i }) fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Daily Check-in' } }) fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Ask about ceremonial coffee' }, }) fireEvent.change(screen.getByLabelText(/^Email Period$/i), { target: { value: 'last_day' }, }) fireEvent.change(screen.getByLabelText(/^Generation Source$/i), { target: { value: 'llama' }, }) fireEvent.click(screen.getByRole('button', { name: /Weekdays/i })) fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } }) expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument() expect( screen.getByText( 'PROMPT FOR Entity A: Daily Check-in | Ask about ceremonial coffee | 0 8 * * 1-5 | last_day | llama' ) ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) await waitFor(() => { expect(tasksApi.generateTaskPreview).toHaveBeenCalled() expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith( mockEntity, expect.objectContaining({ entityId: 'entity-1', name: 'Daily Check-in', prompt: 'Ask about ceremonial coffee', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_day', generationSource: 'llama', }) ) expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual('task-1') expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][1]).toEqual( expect.objectContaining({ entity: mockEntity, task: expect.objectContaining({ entityId: 'entity-1', name: 'Daily Check-in', prompt: 'Ask about ceremonial coffee', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_day', generationSource: 'llama', }), }) ) expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() }) fireEvent.click(screen.getByRole('button', { name: /save changes/i })) await waitFor(() => { expect(tasksApi.updateTask).toHaveBeenCalledWith('task-1', { entityId: 'entity-1', name: 'Daily Check-in', prompt: 'Ask about ceremonial coffee', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_day', generationSource: 'llama', }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') }) }) it('should_showReadablePreviewError_when_generationFails', async () => { vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue( new Error('Unable to generate a test message from the local model. Connection refused') ) renderPage() await screen.findByRole('link', { name: /back to entity a/i }) fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) await waitFor(() => { expect( screen.getByText(/Unable to generate a test message from the local model. Connection refused/i) ).toBeInTheDocument() }) }) it('should_renderGeneratedMessagePanels_when_pageLoads', async () => { renderPage() await screen.findByRole('link', { name: /back to entity a/i }) expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument() expect(screen.getByText(/^Generated Message$/i)).toBeInTheDocument() expect(screen.getByText(/^Generated Message History$/i)).toBeInTheDocument() expect(screen.getByText(/Generate a message and it will appear here./i)).toBeInTheDocument() expect(screen.getByRole('list', { name: /generated message history/i })).toBeInTheDocument() expect(screen.getByText(/No generated messages yet./i)).toBeInTheDocument() }) it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => { vi.mocked(tasksApi.generateTaskPreview) .mockImplementationOnce(async () => { const content = 'SUBJECT: First\nBODY:\nFirst output' persistedHistory = [ { id: 'message-1', taskId: 'task-1', label: 'Message #1', content, createdAt: '2026-03-27T12:00:00Z', }, ...persistedHistory, ] return content }) .mockImplementationOnce(async () => { const content = 'SUBJECT: Second\nBODY:\nSecond output' persistedHistory = [ { id: 'message-2', taskId: 'task-1', label: 'Message #2', content, createdAt: '2026-03-27T12:10:00Z', }, ...persistedHistory, ] return content }) renderPage() await screen.findByRole('link', { name: /back to entity a/i }) const generateButton = screen.getByRole('button', { name: /generate test message/i }) fireEvent.click(generateButton) await screen.findByText(/First output/i) fireEvent.click(generateButton) await waitFor(() => { expect(tasksApi.generateTaskPreview).toHaveBeenCalledTimes(2) }) const history = screen.getByRole('list', { name: /generated message history/i }) expect(history).toBeInTheDocument() const firstMessageHistoryItem = screen.getByRole('button', { name: /^message #1$/i }) const secondMessageHistoryItem = screen.getByRole('button', { name: /^message #2$/i }) fireEvent.click(secondMessageHistoryItem) expect(screen.getByText(/Second output/i)).toBeInTheDocument() fireEvent.click(firstMessageHistoryItem) expect(screen.getByText(/First output/i)).toBeInTheDocument() fireEvent.click( screen.getByRole('button', { name: /delete message #1/i, }) ) await waitFor(() => { expect(tasksApi.deleteTaskGeneratedMessage).toHaveBeenCalledWith('task-1', 'message-1') expect(firstMessageHistoryItem).not.toBeInTheDocument() expect(secondMessageHistoryItem).toBeInTheDocument() }) }) it('should_removeOnlyDeletedGeneratedMessage_when_deleteSucceedsWithoutRefetch', async () => { persistedHistory = [ { id: 'message-2', taskId: 'task-1', label: 'Message #2', content: 'SUBJECT: Second\nBODY:\nSecond output', createdAt: '2026-03-27T12:10:00Z', }, { id: 'message-1', taskId: 'task-1', label: 'Message #1', content: 'SUBJECT: First\nBODY:\nFirst output', createdAt: '2026-03-27T12:00:00Z', }, ] vi.mocked(tasksApi.getTaskGeneratedMessages).mockResolvedValue(persistedHistory) vi.mocked(tasksApi.deleteTaskGeneratedMessage).mockResolvedValue(undefined) renderPage() const secondMessageHistoryItem = await screen.findByRole('button', { name: /^message #2$/i }) expect(await screen.findByRole('button', { name: /^message #1$/i })).toBeInTheDocument() fireEvent.click( screen.getByRole('button', { name: /delete message #1/i, }) ) await waitFor(() => { expect(tasksApi.deleteTaskGeneratedMessage).toHaveBeenCalledWith('task-1', 'message-1') expect(screen.queryByRole('button', { name: /^message #1$/i })).not.toBeInTheDocument() expect(secondMessageHistoryItem).toBeInTheDocument() expect(screen.getByText(/Second output/i)).toBeInTheDocument() }) }) it('should_loadPersistedGeneratedMessageHistory_when_pageLoads', async () => { persistedHistory = [ { id: 'message-1', taskId: 'task-1', label: 'Message #1', content: 'SUBJECT: Persisted\nBODY:\nFrom storage', createdAt: '2026-03-27T12:00:00Z', }, ] renderPage() await screen.findByRole('button', { name: /^message #1$/i }) expect(screen.getByText(/From storage/i)).toBeInTheDocument() }) it('should_renderActivateButton_when_taskIsInactive', async () => { renderPage({ task: { ...mockTask, active: false } }) const activateButton = await screen.findByRole('button', { name: /activate/i }) expect(activateButton).toHaveClass('border-emerald-700') expect(activateButton).toHaveClass('text-emerald-200') expect(screen.queryByRole('button', { name: /^inactivate$/i })).not.toBeInTheDocument() }) it('should_inactivateTaskAndNavigateBack_when_inactivateClicked', async () => { const { queryClient } = renderPage() const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') const inactivateButton = await screen.findByRole('button', { name: /^inactivate$/i }) expect(inactivateButton).toHaveClass('border-amber-700') expect(inactivateButton).toHaveClass('text-amber-200') fireEvent.click(inactivateButton) await waitFor(() => { expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-1') expect(tasksApi.activateTask).not.toHaveBeenCalled() expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') }) }) it('should_activateTaskAndNavigateBack_when_activateClicked', async () => { const { queryClient } = renderPage({ task: { ...mockTask, active: false } }) const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') const activateButton = await screen.findByRole('button', { name: /^activate$/i }) fireEvent.click(activateButton) await waitFor(() => { expect(tasksApi.activateTask).toHaveBeenCalledWith('task-1') expect(tasksApi.inactivateTask).not.toHaveBeenCalled() expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') }) }) it('should_deleteTaskAndNavigateBack_when_deleteClicked', async () => { const { queryClient } = renderPage() const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') await screen.findByRole('link', { name: /back to entity a/i }) fireEvent.click(screen.getByRole('button', { name: /delete/i })) await waitFor(() => { expect(tasksApi.deleteTask).toHaveBeenCalledWith('task-1') expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] }) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] }) expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') }) }) })