admin/hooks/useAdminCrudFormModal.test.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

176 lines
6.4 KiB
TypeScript

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<ServiceResult<unknown>>>()
const update = vi.fn<(id: string, payload: { name: string }) => Promise<ServiceResult<unknown>>>()
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)
})
})