proposal-system/web/src/domain/__tests__/admin.use-cases.test.tsx
Adam Moussa 1f55a59445
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup)
Correctness:
- State-transition mutations now invalidate every cached view via
  invalidateProposalViews (detail + line items + lists + stats + admin
  dashboard) — approving no longer leaves a stale queue for the
  5-minute staleTime
- Presigned S3 PUT moved to proposals/api.ts with res.ok check — a
  rejected upload is no longer confirmed as uploaded
- toCustomerRequest always sends contactEmail ('' clears); API create
  path normalizes empty->null to match the update path — customer
  emails can now be cleared from the UI
- Shared Number-based numeric form fields (domain/shared/formFields):
  '12abc' no longer silently coerces to 12 in the pricing library
- Customer create/update invalidate customersKeys.all so cached search
  autocompletes see new customers
- AdminWorkspace clears dirty right after a successful implicit save,
  before approve — no false unsaved-changes prompt when approve fails
- ProposalFormPage submit gate and missing-fields caption derive from
  ONE checks list (missing customer is now listed)
- Empty states gated on !err in ProposalListPage/AdminDashboard — no
  contradictory error + 'no proposals' UI
- VendorDataPanel migrated to useVendorProposals (kills the divergent
  ['vendorProposals', id] cache key and the inline apiClient query)
- useCustomerList/usePricingLibraryList get keepPreviousData — no
  TablePagination out-of-range flash on page change

Cleanup:
- Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem,
  useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema,
  toUpdateLineItemEntry); tests moved to the live save path
  (useSaveProposalWorkspace)
- Shared useDebouncedValue hook replaces 4 drifted inline debounce
  copies (one leaked its timer on unmount, two hardcoded 300ms);
  DEBOUNCE_AUTOCOMPLETE=300 named
- Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated
  onError handlers (CLAUDE.md traceability)
- shared/api-contracts gains an exports map; /schemas resolver alias
  deduplicated from 3 copies to the tsconfig paths mapping

Verify: tsc clean, vitest 51/51 (tests updated to pin the new
invalidation/mapper behavior + new '12abc' rejection test),
vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00

99 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();
});
});