From 9703bb8fce1862c262e96adb2ff7774bd44f75b0 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 21 Jan 2026 12:00:07 +0800 Subject: [PATCH] feat: add retry functionality to use-template-actions hook Add retry function to handle failed template operations by storing last params in ref and allowing retry without re-passing parameters. Includes comprehensive test coverage. Co-Authored-By: Claude Opus 4.5 --- hooks/use-template-actions.test.ts | 248 +++++++++++++++++++++++++++++ hooks/use-template-actions.ts | 12 +- 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 hooks/use-template-actions.test.ts diff --git a/hooks/use-template-actions.test.ts b/hooks/use-template-actions.test.ts new file mode 100644 index 0000000..be3f555 --- /dev/null +++ b/hooks/use-template-actions.test.ts @@ -0,0 +1,248 @@ +import { renderHook, act } from '@testing-library/react-native' +import { useTemplateActions } from './use-template-actions' +import { root } from '@repo/core' +import { TemplateController } from '@repo/sdk' +import { handleError } from './use-error' + +jest.mock('@repo/core', () => ({ + root: { + get: jest.fn(), + }, +})) + +jest.mock('./use-error', () => ({ + handleError: jest.fn(async (cb) => { + try { + const data = await cb() + return { data, error: null } + } catch (e) { + return { data: null, error: e } + } + }), +})) + +describe('useTemplateActions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('initial state', () => { + it('should return initial state', () => { + const { result } = renderHook(() => useTemplateActions()) + + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + }) + + describe('runTemplate function', () => { + it('should run template successfully', async () => { + const mockData = { generationId: 'gen-123' } + const mockController = { + run: jest.fn().mockResolvedValue(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + let response + await act(async () => { + response = await result.current.runTemplate({ + templateId: 'template-1', + data: { key: 'value' }, + }) + }) + + expect(mockController.run).toHaveBeenCalledWith({ + templateId: 'template-1', + data: { key: 'value' }, + }) + expect(response).toEqual({ generationId: 'gen-123' }) + expect(result.current.error).toBeNull() + }) + + it('should handle API errors', async () => { + const mockError = { + status: 500, + statusText: 'Internal Server Error', + message: 'Failed to run template', + } + + const mockController = { + run: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + let response + await act(async () => { + response = await result.current.runTemplate({ + templateId: 'template-1', + data: {}, + }) + }) + + expect(result.current.error).toEqual(mockError) + expect(response).toEqual({ error: mockError }) + }) + }) + + describe('loading state', () => { + it('should set loading to true during execution', async () => { + let resolveFetch: (value: any) => void + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve + }) + + const mockController = { + run: jest.fn().mockReturnValue(fetchPromise), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + act(() => { + result.current.runTemplate({ templateId: 'template-1', data: {} }) + }) + + expect(result.current.loading).toBe(true) + + await act(async () => { + resolveFetch!({ generationId: 'gen-123' }) + await fetchPromise + }) + + expect(result.current.loading).toBe(false) + }) + + it('should set loading to false after error', async () => { + const mockError = { status: 500, message: 'Error' } + + const mockController = { + run: jest.fn().mockRejectedValue(mockError), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + await act(async () => { + await result.current.runTemplate({ templateId: 'template-1', data: {} }) + }) + + expect(result.current.loading).toBe(false) + }) + }) + + describe('retry function', () => { + it('should retry last failed operation', async () => { + const mockError = { status: 500, message: 'Error' } + const mockData = { generationId: 'gen-123' } + + const mockController = { + run: jest.fn() + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + await act(async () => { + await result.current.runTemplate({ + templateId: 'template-1', + data: { key: 'value' }, + }) + }) + + expect(result.current.error).toEqual(mockError) + + let response + await act(async () => { + response = await result.current.retry() + }) + + expect(mockController.run).toHaveBeenCalledTimes(2) + expect(mockController.run).toHaveBeenLastCalledWith({ + templateId: 'template-1', + data: { key: 'value' }, + }) + expect(response).toEqual({ generationId: 'gen-123' }) + expect(result.current.error).toBeNull() + }) + + it('should return error if no previous params', async () => { + const { result } = renderHook(() => useTemplateActions()) + + let response + await act(async () => { + response = await result.current.retry() + }) + + expect(response).toEqual({ + error: { message: 'No previous operation to retry' }, + }) + }) + + it('should clear error on successful retry', async () => { + const mockError = { status: 500, message: 'Error' } + const mockData = { generationId: 'gen-123' } + + const mockController = { + run: jest.fn() + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + await act(async () => { + await result.current.runTemplate({ + templateId: 'template-1', + data: {}, + }) + }) + + expect(result.current.error).toEqual(mockError) + + await act(async () => { + await result.current.retry() + }) + + expect(result.current.error).toBeNull() + }) + }) + + describe('error handling', () => { + it('should clear error on successful runTemplate', async () => { + const mockError = { status: 500, message: 'Error' } + const mockData = { generationId: 'gen-123' } + + const mockController = { + run: jest.fn() + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockData), + } + ;(root.get as jest.Mock).mockReturnValue(mockController) + + const { result } = renderHook(() => useTemplateActions()) + + await act(async () => { + await result.current.runTemplate({ templateId: 'template-1', data: {} }) + }) + + expect(result.current.error).toEqual(mockError) + + await act(async () => { + await result.current.runTemplate({ templateId: 'template-2', data: {} }) + }) + + expect(result.current.error).toBeNull() + }) + }) +}) diff --git a/hooks/use-template-actions.ts b/hooks/use-template-actions.ts index 9cce64f..cc52ee4 100644 --- a/hooks/use-template-actions.ts +++ b/hooks/use-template-actions.ts @@ -1,6 +1,6 @@ import { root } from '@repo/core' import { type RunTemplateInput, TemplateController } from '@repo/sdk' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { type ApiError } from '@/lib/types' import { handleError } from './use-error' @@ -8,10 +8,12 @@ import { handleError } from './use-error' export const useTemplateActions = () => { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const lastParamsRef = useRef(null) const runTemplate = useCallback(async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => { setLoading(true) setError(null) + lastParamsRef.current = params const template = root.get(TemplateController) const { data, error } = await handleError(async () => await template.run({ @@ -29,9 +31,17 @@ export const useTemplateActions = () => { return { generationId: data?.generationId } }, []) + const retry = useCallback(async (): Promise<{ generationId?: string; error?: ApiError }> => { + if (!lastParamsRef.current) { + return { error: { message: 'No previous operation to retry' } as ApiError } + } + return runTemplate(lastParamsRef.current) + }, [runTemplate]) + return { loading, error, runTemplate, + retry, } }