Files
condado-newsletter/frontend/src/__tests__/pages/EditTaskPage.test.tsx

416 lines
15 KiB
TypeScript

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 }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/entities/entity-1/tasks/task-1']}>
<Routes>
<Route path="/entities/:entityId/tasks/:taskId" element={children} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
vi.mocked(tasksApi.getTask).mockResolvedValue(options.task ?? mockTask)
render(<EditTaskPage />, { 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,
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}`
)
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(/^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',
})
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.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'
)
).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',
})
)
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',
}),
})
)
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',
})
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')
})
})
})