diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx new file mode 100644 index 0000000..617ee08 --- /dev/null +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -0,0 +1,87 @@ +// 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), + }); + 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(); + }); +}); diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx new file mode 100644 index 0000000..65b0e66 --- /dev/null +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -0,0 +1,98 @@ +// Customers domain use-case hooks: create/update must invalidate the list +// key family (customersKeys.lists()) and toast. The domain api module is +// 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 { + customersKeys, + useCreateCustomer, + useUpdateCustomer, +} from '../customers/use-cases'; +import { customersApi } from '../customers/api'; +import type { Customer } from '../customers/types'; + +vi.mock('../customers/api', () => ({ + customersApi: { + search: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const customer = { id: 'c-1', name: 'Acme Corp' } as unknown as Customer; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('customersKeys', () => { + it('list keys nest under lists() so one invalidation hits every page/filter', () => { + expect(customersKeys.all).toEqual(['customers']); + expect(customersKeys.lists()).toEqual(['customers', 'list']); + expect(customersKeys.list({ page: 3 })).toEqual(['customers', 'list', { page: 3 }]); + expect(customersKeys.search('acme')).toEqual(['customers', 'search', 'acme']); + }); +}); + +describe('useCreateCustomer', () => { + it('invalidates customersKeys.lists() and toasts on success', async () => { + vi.mocked(customersApi.create).mockResolvedValue(customer); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper }); + result.current.mutate({ name: 'Acme Corp' } as never); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); + expect(toast.success).toHaveBeenCalledWith('Customer created'); + }); + + it('toasts the failure and skips invalidation', async () => { + vi.mocked(customersApi.create).mockRejectedValue(new Error('duplicate name')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper }); + result.current.mutate({ name: 'Acme Corp' } as never); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Failed to create customer: duplicate name'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); + +describe('useUpdateCustomer', () => { + it('passes id + payload through and invalidates customersKeys.lists() on success', async () => { + vi.mocked(customersApi.update).mockResolvedValue(customer); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper }); + const data = { name: 'Acme Corp (Renamed)' } as never; + result.current.mutate({ id: 'c-1', data }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(customersApi.update).toHaveBeenCalledWith('c-1', data); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); + expect(toast.success).toHaveBeenCalledWith('Customer updated'); + }); + + it('toasts the failure on update error', async () => { + vi.mocked(customersApi.update).mockRejectedValue(new Error('not found')); + const { wrapper } = createQueryHarness(); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper }); + result.current.mutate({ id: 'c-404', data: { name: 'x' } as never }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Failed to update customer: not found'); + }); +}); diff --git a/web/src/domain/__tests__/hookTestUtils.tsx b/web/src/domain/__tests__/hookTestUtils.tsx new file mode 100644 index 0000000..09a4044 --- /dev/null +++ b/web/src/domain/__tests__/hookTestUtils.tsx @@ -0,0 +1,18 @@ +// Shared harness for domain use-case hook tests: a fresh QueryClient per +// test (no retries, so error paths settle immediately) plus the provider +// wrapper renderHook needs. Not a test file — no .test suffix. +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export function createQueryHarness() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, wrapper }; +} diff --git a/web/src/domain/__tests__/lineItems.use-cases.test.tsx b/web/src/domain/__tests__/lineItems.use-cases.test.tsx new file mode 100644 index 0000000..6b32dfa --- /dev/null +++ b/web/src/domain/__tests__/lineItems.use-cases.test.tsx @@ -0,0 +1,74 @@ +// Line items domain use-case hooks: bulk update must invalidate BOTH the +// proposal's line-items key and the proposal detail key (totals change). +// The domain api module is 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 { lineItemsKeys, useBulkUpdateLineItems } from '../lineItems/use-cases'; +import { proposalsKeys } from '../proposals/use-cases'; +import { lineItemsApi } from '../lineItems/api'; +import type { UpdateLineItemEntry } from '../lineItems/types'; + +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-42'; +const entries = [ + { description: 'Labor', quantity: 2, unitPrice: 150 }, +] as unknown as UpdateLineItemEntry[]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('lineItemsKeys', () => { + it('scopes the by-proposal key under ["lineItems"]', () => { + expect(lineItemsKeys.all).toEqual(['lineItems']); + expect(lineItemsKeys.byProposal(PROPOSAL_ID)).toEqual(['lineItems', PROPOSAL_ID]); + }); +}); + +describe('useBulkUpdateLineItems', () => { + it('sends the entries and invalidates the proposal line-items + detail keys on success', async () => { + vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); + result.current.mutate(entries); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), + }); + // Totals live on the proposal detail — it must be refetched too. + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.detail(PROPOSAL_ID), + }); + }); + + it('toasts a save failure and skips invalidation', async () => { + vi.mocked(lineItemsApi.bulkUpdate).mockRejectedValue(new Error('409 conflict')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); + result.current.mutate(entries); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/domain/__tests__/proposals.use-cases.test.tsx b/web/src/domain/__tests__/proposals.use-cases.test.tsx new file mode 100644 index 0000000..cc57edf --- /dev/null +++ b/web/src/domain/__tests__/proposals.use-cases.test.tsx @@ -0,0 +1,126 @@ +// Proposals domain use-case hooks: query key shape, list caching under the +// domain key, and create-mutation invalidation/toast behavior. The domain +// api module is 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 { + proposalsKeys, + useProposals, + useCreateProposal, +} from '../proposals/use-cases'; +import { proposalsApi } from '../proposals/api'; +import type { ProposalDetail, ProposalFilters, ProposalListItem, PagedResponse } from '../proposals/types'; + +vi.mock('../proposals/api', () => ({ + proposalsApi: { + create: vi.fn(), + getAll: vi.fn(), + getById: vi.fn(), + uploadAttachment: vi.fn(), + confirmUpload: vi.fn(), + getStats: vi.fn(), + getVendors: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const proposal = { + id: 'p-1', + proposalNumber: 'PRO-0001', +} as unknown as ProposalDetail; + +const pagedList: PagedResponse = { + items: [], + page: 2, + pageSize: 25, + totalCount: 0, +} as unknown as PagedResponse; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('proposalsKeys', () => { + it('follows the hierarchical TanStack key convention rooted at ["proposals"]', () => { + expect(proposalsKeys.all).toEqual(['proposals']); + expect(proposalsKeys.lists()).toEqual(['proposals', 'list']); + expect(proposalsKeys.list({ page: 2, status: 'InReview' })).toEqual([ + 'proposals', + 'list', + { page: 2, status: 'InReview' }, + ]); + expect(proposalsKeys.detail('p-1')).toEqual(['proposals', 'p-1']); + expect(proposalsKeys.stats()).toEqual(['proposals', 'stats']); + expect(proposalsKeys.vendors('p-1')).toEqual(['proposals', 'p-1', 'vendors']); + }); +}); + +describe('useProposals', () => { + it('fetches with the given filters and caches under proposalsKeys.list(filters)', async () => { + const filters: ProposalFilters = { page: 2, pageSize: 25 }; + vi.mocked(proposalsApi.getAll).mockResolvedValue(pagedList); + const { queryClient, wrapper } = createQueryHarness(); + + const { result } = renderHook(() => useProposals(filters), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(proposalsApi.getAll).toHaveBeenCalledWith(filters); + // The data must live under the domain's list key — pages and cross-domain + // invalidation both depend on this exact shape. + expect(queryClient.getQueryData(proposalsKeys.list(filters))).toBe(pagedList); + }); +}); + +describe('useCreateProposal', () => { + it('invalidates proposalsKeys.all and toasts on success', async () => { + vi.mocked(proposalsApi.create).mockResolvedValue(proposal); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ request: {} as never }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Proposal PRO-0001 submitted'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('toasts the error message and does not invalidate on failure', async () => { + vi.mocked(proposalsApi.create).mockRejectedValue(new Error('boom')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ request: {} as never }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('boom'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); + + it('still succeeds (warning toast, invalidation) when the vendor PDF upload fails', async () => { + vi.mocked(proposalsApi.create).mockResolvedValue(proposal); + vi.mocked(proposalsApi.uploadAttachment).mockRejectedValue(new Error('presign failed')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ + request: {} as never, + vendorFile: new File(['pdf'], 'vendor.pdf', { type: 'application/pdf' }), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // A failed attachment must not fail the proposal creation. + expect(proposalsApi.confirmUpload).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining('PRO-0001')); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Proposal PRO-0001 submitted'); + }); +});