All checks were successful
Build And Publish Production Image / Build And Publish Production Image (push) Successful in 50s
425 lines
16 KiB
TypeScript
425 lines
16 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,
|
|
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')
|
|
})
|
|
})
|
|
}) |