Replace the local preview stub with a real Ollama-backed test message flow using the configured local model. Show the exact final prompt live on create and edit task pages, render generated output below it, and cover the integration with frontend tests.
252 lines
9.8 KiB
TypeScript
252 lines
9.8 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',
|
|
}
|
|
|
|
describe('EditTaskPage', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
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)
|
|
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).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
|
|
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(
|
|
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_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')
|
|
})
|
|
})
|
|
}) |