mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-02 22:43:18 +00:00
100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
|
|
// 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();
|
||
|
|
});
|
||
|
|
});
|