import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal' import type { ServiceResult } from '@/services/errorHandler' const addToast = vi.fn() vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) interface Entity { id: string name: string } interface FormValues { name: string } const emptyValues: FormValues = { name: '' } const toFormValues = (entity: Entity): FormValues => ({ name: entity.name }) const buildPayload = (values: FormValues) => ({ name: values.name }) const resolver = vi.fn(async (values: FormValues) => ({ values, errors: {} })) const setup = () => { const create = vi.fn<(payload: { name: string }) => Promise>>() const update = vi.fn<(id: string, payload: { name: string }) => Promise>>() const onOpenChange = vi.fn() const onSuccess = vi.fn() const { result, rerender } = renderHook( (props: { entity: Entity | undefined; isOpen: boolean }) => useAdminCrudFormModal({ entity: props.entity, isOpen: props.isOpen, emptyValues, toFormValues, resolver: resolver as any, buildPayload, create, update, getId: (entity) => entity.id, successMessage: { create: 'ایجاد شد', edit: 'ویرایش شد' }, onOpenChange, onSuccess, }), { initialProps: { entity: undefined as Entity | undefined, isOpen: false } } ) return { result, rerender, create, update, onOpenChange, onSuccess } } describe('useAdminCrudFormModal', () => { beforeEach(() => addToast.mockClear()) it('derives isEdit from whether an entity is present', () => { const { result: createResult } = setup() expect(createResult.current.isEdit).toBe(false) // `entity` is a dependency of the hook's reset-on-open effect, so it // must be a stable reference across renders here — an inline object // literal recreated on every render of this renderHook callback would // make the effect refire every render (reset -> re-render -> new // `entity` identity -> effect deps changed -> reset -> ...), an // infinite loop. Hoisting it above avoids that footgun. const existingEntity: Entity = { id: '1', name: 'existing' } const { result: editResult } = renderHook(() => useAdminCrudFormModal({ entity: existingEntity, isOpen: true, emptyValues, toFormValues, resolver: resolver as any, buildPayload, create: vi.fn(), update: vi.fn(), getId: (entity) => entity.id, successMessage: { create: 'ایجاد شد', edit: 'ویرایش شد' }, onOpenChange: vi.fn(), onSuccess: vi.fn(), }) ) expect(editResult.current.isEdit).toBe(true) }) it('resets the form to the entity values only on the isOpen transition, not on every render', () => { const { result, rerender } = setup() // Opening with an entity resets to its mapped values. rerender({ entity: { id: '1', name: 'Ali' }, isOpen: true }) expect(result.current.form.getValues()).toEqual({ name: 'Ali' }) // A render while still open with a *different* entity object identity // but the effect's dependency array (`entity`) does change here, so it // re-resets — this models the "modal re-opens for a different node" // case the source comment documents, not a same-entity re-render. rerender({ entity: { id: '2', name: 'Reza' }, isOpen: true }) expect(result.current.form.getValues()).toEqual({ name: 'Reza' }) // Closing does not clear the form back to empty values (no reset fires // while isOpen is false) — the modal is expected to unmount/hide, not // to have its draft wiped on close. rerender({ entity: { id: '2', name: 'Reza' }, isOpen: false }) expect(result.current.form.getValues()).toEqual({ name: 'Reza' }) }) it('resets to emptyValues when opening for create (no entity)', () => { const { result, rerender } = setup() rerender({ entity: undefined, isOpen: true }) expect(result.current.form.getValues()).toEqual({ name: '' }) }) it('on submit success: shows the success toast, closes, then refreshes — in that order, no earlier', async () => { const { result, rerender, create, onOpenChange, onSuccess } = setup() create.mockResolvedValue({ ok: true, data: {} }) rerender({ entity: undefined, isOpen: true }) const callOrder: string[] = [] onOpenChange.mockImplementation(() => callOrder.push('close')) onSuccess.mockImplementation(() => callOrder.push('success')) addToast.mockImplementation(() => callOrder.push('toast')) await act(async () => { result.current.form.setValue('name', 'New item') await result.current.handleSubmit() }) expect(create).toHaveBeenCalledWith({ name: 'New item' }) expect(addToast).toHaveBeenCalledWith({ title: 'ایجاد شد', color: 'success' }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(onSuccess).toHaveBeenCalledOnce() expect(callOrder).toEqual(['toast', 'close', 'success']) expect(result.current.submitting).toBe(false) }) it('on submit success while editing: calls update with the entity id, not create', async () => { const entity = { id: 'entity-1', name: 'Existing' } const { result, rerender, create, update } = setup() update.mockResolvedValue({ ok: true, data: {} }) rerender({ entity, isOpen: true }) await act(async () => { result.current.form.setValue('name', 'Renamed') await result.current.handleSubmit() }) expect(update).toHaveBeenCalledWith('entity-1', { name: 'Renamed' }) expect(create).not.toHaveBeenCalled() expect(addToast).toHaveBeenCalledWith({ title: 'ویرایش شد', color: 'success' }) }) it('on submit failure: does not toast, does not close, and resets submitting to false', async () => { const { result, rerender, create, onOpenChange, onSuccess } = setup() create.mockResolvedValue({ ok: false, error: { message: 'خطا' } as never }) rerender({ entity: undefined, isOpen: true }) await act(async () => { result.current.form.setValue('name', 'New item') await result.current.handleSubmit() }) expect(addToast).not.toHaveBeenCalled() expect(onOpenChange).not.toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() expect(result.current.submitting).toBe(false) }) })