diff --git a/frontend/src/api/__tests__/workflows.test.ts b/frontend/src/api/__tests__/workflows.test.ts new file mode 100644 index 0000000..cc2de45 --- /dev/null +++ b/frontend/src/api/__tests__/workflows.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the API client +const mockApiGet = vi.fn(); +const mockApiPost = vi.fn(); +const mockApiPatch = vi.fn(); +const mockApiDelete = vi.fn(); + +vi.mock('@/api/client', () => ({ + apiGet: (...args: any[]) => mockApiGet(...args), + apiPost: (...args: any[]) => mockApiPost(...args), + apiPatch: (...args: any[]) => mockApiPatch(...args), + apiDelete: (...args: any[]) => mockApiDelete(...args), +})); + +// Mock react-query +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + }), + useQuery: vi.fn(), + useMutation: vi.fn(({ mutationFn, onSuccess }) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })), +})); + +describe('Workflow API Hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useWorkflows', () => { + it('calls apiGet with correct endpoint', async () => { + const { useWorkflows } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => { + expect(queryKey[0]).toBe('workflows'); + queryFn(); + return { data: undefined, isLoading: false }; + }); + + useWorkflows(); + expect(mockApiGet).toHaveBeenCalledWith( + expect.stringContaining('/workflows?') + ); + }); + + it('passes is_active filter when provided', async () => { + const { useWorkflows } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryFn }: any) => { + queryFn(); + return { data: undefined, isLoading: false }; + }); + + useWorkflows(1, 20, true); + expect(mockApiGet).toHaveBeenCalledWith( + expect.stringContaining('is_active=true') + ); + }); + }); + + describe('useWorkflow', () => { + it('calls apiGet with correct endpoint', async () => { + const { useWorkflow } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryKey, queryFn, enabled }: any) => { + expect(queryKey).toEqual(['workflows', 'wf-123']); + expect(enabled).toBe(true); + queryFn(); + return { data: undefined, isLoading: false }; + }); + + useWorkflow('wf-123'); + expect(mockApiGet).toHaveBeenCalledWith('/workflows/wf-123'); + }); + + it('is disabled when id is undefined', async () => { + const { useWorkflow } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ enabled }: any) => { + expect(enabled).toBe(false); + return { data: undefined, isLoading: false }; + }); + + useWorkflow(undefined); + expect(mockApiGet).not.toHaveBeenCalled(); + }); + }); + + describe('useCreateWorkflow', () => { + it('calls apiPost with correct endpoint and data', async () => { + const { useCreateWorkflow } = await import('../workflows'); + const { useMutation } = await import('@tanstack/react-query'); + + (useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })); + + const { mutateAsync } = useCreateWorkflow(); + const payload = { + name: 'Test WF', + steps: [{ name: 'S1', type: 'action' as const, config: {} }], + }; + mockApiPost.mockResolvedValue({ id: 'wf-1', ...payload }); + await mutateAsync(payload); + expect(mockApiPost).toHaveBeenCalledWith('/workflows', payload); + }); + }); + + describe('useUpdateWorkflow', () => { + it('calls apiPatch with correct endpoint and data', async () => { + const { useUpdateWorkflow } = await import('../workflows'); + const { useMutation } = await import('@tanstack/react-query'); + + (useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })); + + const { mutateAsync } = useUpdateWorkflow(); + const payload = { id: 'wf-1', data: { name: 'Updated' } }; + mockApiPatch.mockResolvedValue({ id: 'wf-1', name: 'Updated' }); + await mutateAsync(payload); + expect(mockApiPatch).toHaveBeenCalledWith('/workflows/wf-1', { + name: 'Updated', + }); + }); + }); + + describe('useDeleteWorkflow', () => { + it('calls apiDelete with correct endpoint', async () => { + const { useDeleteWorkflow } = await import('../workflows'); + const { useMutation } = await import('@tanstack/react-query'); + + (useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })); + + const { mutateAsync } = useDeleteWorkflow(); + mockApiDelete.mockResolvedValue(undefined); + await mutateAsync('wf-1'); + expect(mockApiDelete).toHaveBeenCalledWith('/workflows/wf-1'); + }); + }); + + describe('useWorkflowInstances', () => { + it('calls apiGet with instances endpoint', async () => { + const { useWorkflowInstances } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryFn }: any) => { + queryFn(); + return { data: undefined, isLoading: false }; + }); + + useWorkflowInstances(); + expect(mockApiGet).toHaveBeenCalledWith( + expect.stringContaining('/workflows/instances?') + ); + }); + + it('passes status filter when provided', async () => { + const { useWorkflowInstances } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryFn }: any) => { + queryFn(); + return { data: undefined, isLoading: false }; + }); + + useWorkflowInstances(1, 20, 'completed'); + expect(mockApiGet).toHaveBeenCalledWith( + expect.stringContaining('status=completed') + ); + }); + }); + + describe('useWorkflowInstance', () => { + it('calls apiGet with instance detail endpoint', async () => { + const { useWorkflowInstance } = await import('../workflows'); + const { useQuery } = await import('@tanstack/react-query'); + + (useQuery as any).mockImplementation(({ queryFn, enabled }: any) => { + expect(enabled).toBe(true); + queryFn(); + return { data: undefined, isLoading: false }; + }); + + useWorkflowInstance('inst-1'); + expect(mockApiGet).toHaveBeenCalledWith( + '/workflows/instances/inst-1' + ); + }); + }); + + describe('useCreateWorkflowInstance', () => { + it('calls apiPost with correct endpoint and data', async () => { + const { useCreateWorkflowInstance } = await import('../workflows'); + const { useMutation } = await import('@tanstack/react-query'); + + (useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })); + + const { mutateAsync } = useCreateWorkflowInstance(); + const payload = { + workflowId: 'wf-1', + data: { context: { foo: 'bar' } }, + }; + mockApiPost.mockResolvedValue({ id: 'inst-1' }); + await mutateAsync(payload); + expect(mockApiPost).toHaveBeenCalledWith( + '/workflows/wf-1/instances', + { context: { foo: 'bar' } } + ); + }); + }); + + describe('useAdvanceWorkflowInstance', () => { + it('calls apiPost with advance endpoint and decision', async () => { + const { useAdvanceWorkflowInstance } = await import('../workflows'); + const { useMutation } = await import('@tanstack/react-query'); + + (useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })); + + const { mutateAsync } = useAdvanceWorkflowInstance(); + const payload = { + instanceId: 'inst-1', + data: { decision: 'approve' as const, comment: 'LGTM' }, + }; + mockApiPost.mockResolvedValue({ id: 'inst-1', status: 'in_progress' }); + await mutateAsync(payload); + expect(mockApiPost).toHaveBeenCalledWith( + '/workflows/instances/inst-1/advance', + { decision: 'approve', comment: 'LGTM' } + ); + }); + }); + + describe('useCancelWorkflowInstance', () => { + it('calls apiPost with cancel endpoint', async () => { + const { useCancelWorkflowInstance } = await import('../workflows'); + const { useMutation } = await import('@tanstack/react-query'); + + (useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({ + mutateAsync: async (args: any) => { + const result = await mutationFn(args); + if (onSuccess) onSuccess(result, args); + return result; + }, + isPending: false, + })); + + const { mutateAsync } = useCancelWorkflowInstance(); + mockApiPost.mockResolvedValue({ id: 'inst-1', status: 'cancelled' }); + await mutateAsync('inst-1'); + expect(mockApiPost).toHaveBeenCalledWith( + '/workflows/instances/inst-1/cancel' + ); + }); + }); +}); diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts new file mode 100644 index 0000000..88a57df --- /dev/null +++ b/frontend/src/api/workflows.ts @@ -0,0 +1,250 @@ +/** + * Workflow API module — TypeScript types and React Query hooks. + * Matches backend endpoints from /api/v1/workflows. + * + * Backend: app/routes/workflows.py, app/models/workflow.py, app/schemas/workflow.py + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPatch, apiDelete } from './client'; +import type { PaginatedResponse } from './types'; + +// ── Types ── + +export interface WorkflowStep { + name: string; + type: 'action' | 'approval' | 'notification' | 'condition'; + config: Record; + description?: string | null; +} + +export interface Workflow { + id: string; + name: string; + description?: string | null; + trigger_event?: string | null; + steps: WorkflowStep[]; + is_active: boolean; + created_by?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface WorkflowListResponse extends PaginatedResponse {} + +export interface WorkflowCreateInput { + name: string; + description?: string | null; + trigger_event?: string | null; + steps: WorkflowStep[]; + is_active?: boolean; +} + +export interface WorkflowUpdateInput { + name?: string; + description?: string | null; + trigger_event?: string | null; + steps?: WorkflowStep[]; + is_active?: boolean; +} + +export type InstanceStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'rejected' + | 'cancelled'; + +export interface WorkflowInstance { + id: string; + workflow_id: string; + status: InstanceStatus; + current_step_index: number; + context: Record; + initiated_by?: string | null; + completed_at?: string | null; + timeout_hours?: number | null; + timeout_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface StepHistoryEntry { + id: string; + instance_id: string; + step_index: number; + step_type: string; + action: string; + actor_id?: string | null; + details?: Record | null; + created_at?: string | null; +} + +export interface WorkflowInstanceDetail extends WorkflowInstance { + history: StepHistoryEntry[]; + workflow_name?: string | null; +} + +export interface InstanceListResponse extends PaginatedResponse {} + +export interface InstanceCreateInput { + context?: Record; + timeout_hours?: number | null; +} + +export interface AdvanceRequest { + decision: 'approve' | 'reject'; + comment?: string | null; +} + +// ── Workflow Definition Hooks ── + +export function useWorkflows(page = 1, pageSize = 20, isActive?: boolean) { + const params = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + if (isActive !== undefined) params.set('is_active', String(isActive)); + return useQuery({ + queryKey: ['workflows', page, pageSize, isActive], + queryFn: () => + apiGet(`/workflows?${params.toString()}`), + }); +} + +export function useWorkflow(id?: string) { + return useQuery({ + queryKey: ['workflows', id], + queryFn: () => apiGet(`/workflows/${id}`), + enabled: !!id, + }); +} + +export function useCreateWorkflow() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: WorkflowCreateInput) => + apiPost('/workflows', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }); + }, + }); +} + +export function useUpdateWorkflow() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + data, + }: { + id: string; + data: WorkflowUpdateInput; + }) => apiPatch(`/workflows/${id}`, data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }); + queryClient.invalidateQueries({ + queryKey: ['workflows', variables.id], + }); + }, + }); +} + +export function useDeleteWorkflow() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/workflows/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }); + }, + }); +} + +// ── Workflow Instance Hooks ── + +export function useWorkflowInstances( + page = 1, + pageSize = 20, + statusFilter?: InstanceStatus +) { + const params = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + if (statusFilter) params.set('status', statusFilter); + return useQuery({ + queryKey: ['workflowInstances', page, pageSize, statusFilter], + queryFn: () => + apiGet( + `/workflows/instances?${params.toString()}` + ), + }); +} + +export function useWorkflowInstance(id?: string) { + return useQuery({ + queryKey: ['workflowInstances', id], + queryFn: () => + apiGet(`/workflows/instances/${id}`), + enabled: !!id, + }); +} + +export function useCreateWorkflowInstance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + workflowId, + data, + }: { + workflowId: string; + data: InstanceCreateInput; + }) => + apiPost( + `/workflows/${workflowId}/instances`, + data + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflowInstances'] }); + }, + }); +} + +export function useAdvanceWorkflowInstance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + instanceId, + data, + }: { + instanceId: string; + data: AdvanceRequest; + }) => + apiPost( + `/workflows/instances/${instanceId}/advance`, + data + ), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['workflowInstances'] }); + queryClient.invalidateQueries({ + queryKey: ['workflowInstances', variables.instanceId], + }); + }, + }); +} + +export function useCancelWorkflowInstance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (instanceId: string) => + apiPost( + `/workflows/instances/${instanceId}/cancel` + ), + onSuccess: (_data, instanceId) => { + queryClient.invalidateQueries({ queryKey: ['workflowInstances'] }); + queryClient.invalidateQueries({ + queryKey: ['workflowInstances', instanceId], + }); + }, + }); +}