// @vitest-environment happy-dom
//
// ApprovalGate: inline PIN grants + parked-request approval, and the capability
// bypass that lets a supervisor skip their own prompt.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
}),
}))
vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { info: vi.fn(), success: vi.fn(), error: vi.fn() }) }))
import { ApiProvider, type ApiClient } from '../api-context'
import { PermissionsProvider } from '../permissions-context'
import {
ApprovalGateProvider,
useApprovalGate,
approvalRequiredInfo,
APPROVAL_PARKED_EVENT,
type ApprovalGrant,
} from '../approval-gate'
import { toastServerError } from '../server-error'
import { toast } from 'sonner'
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
function makeApi(post: ApiClient['post']): ApiClient {
return { get: vi.fn(), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
}
let lastGrant: ApprovalGrant | null | undefined
function Probe({ policy }: { policy: string }) {
const gate = useApprovalGate()
return (
)
}
function mount(post: ApiClient['post'], caps: string[] = [], isAdmin = false) {
lastGrant = undefined
return render(
,
)
}
function fill(reason: string, pin: string) {
fireEvent.change(screen.getByLabelText('Motivo'), { target: { value: reason } })
fireEvent.change(screen.getByLabelText('PIN del supervisor'), { target: { value: pin } })
}
describe('ApprovalGate', () => {
it('a holder of general.approve_ passes without a prompt', async () => {
const post = vi.fn()
mount(post, ['general.approve_oversell'])
await act(async () => fireEvent.click(screen.getByText('ask')))
expect(lastGrant).toEqual({ id: null, policy: 'oversell', bypassed: true })
expect(post).not.toHaveBeenCalled()
expect(screen.queryByLabelText('PIN del supervisor')).toBeNull()
})
it('posts pin-grant with reason + context and resolves the audit id', async () => {
const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'audit-1' } } })
mount(post)
await act(async () => fireEvent.click(screen.getByText('ask')))
const submit = screen.getByText('Autorizar').closest('button')!
expect(submit.hasAttribute('disabled')).toBe(true) // reason + PIN required
fill('cliente esperando', '4321')
await act(async () => fireEvent.click(submit))
await waitFor(() => expect(lastGrant).toBeDefined())
expect(post).toHaveBeenCalledWith(
'/approvals/pin-grant',
expect.objectContaining({ policy: 'oversell', pin: '4321', reason: 'cliente esperando', context: { qty: 3 } }),
)
expect(lastGrant).toMatchObject({ id: 'audit-1', bypassed: false })
})
it('a wrong PIN keeps the dialog open with an inline message and nothing resolves', async () => {
const post = vi.fn().mockRejectedValue({ response: { data: { error: { code: 'approval_pin_invalid' } } } })
mount(post)
await act(async () => fireEvent.click(screen.getByText('ask')))
fill('r', '0000')
await act(async () => fireEvent.click(screen.getByText('Autorizar').closest('button')!))
expect(await screen.findByRole('alert')).toBeTruthy()
expect(lastGrant).toBeUndefined()
})
it('cancelling resolves null (fail closed)', async () => {
mount(vi.fn())
await act(async () => fireEvent.click(screen.getByText('ask')))
await act(async () => fireEvent.click(screen.getByText('Cancelar')))
await waitFor(() => expect(lastGrant).toBeNull())
})
it('a parked approval_required toast offers the PIN and approves through approve-pin', async () => {
const post = vi.fn().mockResolvedValue({ data: { success: true } })
mount(post)
const err = {
response: {
data: { success: false, error: { code: 'approval_required' }, meta: { approval_request_id: 'req-9', approval_label: 'Precio bajo el piso', approval_roles: ['gerente'] } },
},
}
expect(approvalRequiredInfo(err)).toMatchObject({ requestId: 'req-9', roles: ['gerente'] })
toastServerError(err)
const opts = (toast.info as unknown as ReturnType).mock.calls[0][1]
expect(toast.error).not.toHaveBeenCalled()
await act(async () => opts.action.onClick())
fill('autorizado', '1234')
await act(async () => fireEvent.click(screen.getByText('Autorizar').closest('button')!))
await waitFor(() => expect(post).toHaveBeenCalledWith('/approvals/req-9/approve-pin', { pin: '1234', reason: 'autorizado' }))
})
it('without a gate mounted the parked toast has no PIN action and the event is inert', () => {
toastServerError({ response: { data: { error: { code: 'approval_required' }, meta: { approval_request_id: 'x' } } } })
expect((toast.info as unknown as ReturnType).mock.calls[0][1].action).toBeUndefined()
window.dispatchEvent(new CustomEvent(APPROVAL_PARKED_EVENT, { detail: { requestId: 'x', roles: [] } }))
})
})