feat(backend): persist tasks and generated message history
- add EntityTask domain and CRUD API backed by PostgreSQL - relate generated messages directly to tasks and delete on task removal - move preview generation to backend Llama endpoint - migrate frontend task APIs from localStorage to backend endpoints - update tests and CLAUDE rules for backend-owned LLM/persistence
This commit is contained in:
@@ -61,9 +61,18 @@ const mockTask = {
|
||||
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(
|
||||
@@ -73,6 +82,10 @@ describe('EditTaskPage', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -93,7 +106,18 @@ describe('EditTaskPage', () => {
|
||||
})
|
||||
|
||||
it('should_generatePreviewAndUpdateTask_when_formSubmitted', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
|
||||
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',
|
||||
@@ -137,7 +161,8 @@ describe('EditTaskPage', () => {
|
||||
emailLookback: 'last_day',
|
||||
})
|
||||
)
|
||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
||||
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({
|
||||
@@ -201,8 +226,34 @@ describe('EditTaskPage', () => {
|
||||
|
||||
it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => {
|
||||
vi.mocked(tasksApi.generateTaskPreview)
|
||||
.mockResolvedValueOnce('SUBJECT: First\nBODY:\nFirst output')
|
||||
.mockResolvedValueOnce('SUBJECT: Second\nBODY:\nSecond output')
|
||||
.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 })
|
||||
@@ -215,7 +266,7 @@ describe('EditTaskPage', () => {
|
||||
fireEvent.click(generateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
|
||||
expect(tasksApi.generateTaskPreview).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const history = screen.getByRole('list', { name: /generated message history/i })
|
||||
@@ -224,6 +275,9 @@ describe('EditTaskPage', () => {
|
||||
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()
|
||||
|
||||
@@ -234,11 +288,29 @@ describe('EditTaskPage', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tasksApi.deleteTaskGeneratedMessage).toHaveBeenCalledWith('task-1', 'message-1')
|
||||
expect(firstMessageHistoryItem).not.toBeInTheDocument()
|
||||
expect(secondMessageHistoryItem).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 } })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user