// Admin domain use-case hooks: state transitions must cross-domain // invalidate the proposals/lineItems keys (domain README rule 3). The domain // api modules are mocked — no axios traffic. import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { toast } from 'react-toastify'; import { createQueryHarness } from './hookTestUtils'; import { useApproveProposal } from '../admin/use-cases'; import { proposalsKeys } from '../proposals/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases'; import { adminApi } from '../admin/api'; vi.mock('../admin/api', () => ({ adminApi: { getDashboard: vi.fn(), updateProposal: vi.fn(), approveProposal: vi.fn(), sendProposal: vi.fn(), reviseProposal: vi.fn(), returnToReview: vi.fn(), generateSuggestions: vi.fn(), getSimilar: vi.fn(), getHistory: vi.fn(), getAudit: vi.fn(), getPdf: vi.fn(), }, })); // useSaveProposalWorkspace pulls lineItemsApi directly; mock it so no test // path can reach axios. vi.mock('../lineItems/api', () => ({ lineItemsApi: { getAll: vi.fn(), create: vi.fn(), bulkUpdate: vi.fn(), delete: vi.fn(), }, })); vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, })); const PROPOSAL_ID = 'p-7'; beforeEach(() => { vi.clearAllMocks(); }); describe('useApproveProposal', () => { it('invalidates the proposal detail and its line items keys and toasts on success', async () => { vi.mocked(adminApi.approveProposal).mockResolvedValue(undefined); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); result.current.mutate(); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(adminApi.approveProposal).toHaveBeenCalledWith(PROPOSAL_ID); // Approval changes proposal status AND locks/reprices line items — both // domains' keys must be refetched (cross-domain invalidation, rule 3). expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.detail(PROPOSAL_ID), }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), }); // ...AND every cached view of the queue: lists/stats/KPIs sit under the // global 5-minute staleTime, so without these the admin returns to a // stale queue after approving (code-review finding, Phase 4). expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.lists(), }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.stats(), }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['admin', 'dashboard'], }); expect(toast.success).toHaveBeenCalledWith('Proposal approved'); }); it('toasts the failure and skips invalidation when approval is rejected', async () => { vi.mocked(adminApi.approveProposal).mockRejectedValue( new Error('proposal is not in review') ); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); result.current.mutate(); await waitFor(() => expect(result.current.isError).toBe(true)); expect(toast.error).toHaveBeenCalledWith('Approval failed: proposal is not in review'); expect(invalidateSpy).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled(); }); });