mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 13:58:56 +00:00
test(web): domain use-case hook coverage
This commit is contained in:
parent
d1c7e812f6
commit
f456006c30
5 changed files with 403 additions and 0 deletions
87
web/src/domain/__tests__/admin.use-cases.test.tsx
Normal file
87
web/src/domain/__tests__/admin.use-cases.test.tsx
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
98
web/src/domain/__tests__/customers.use-cases.test.tsx
Normal file
98
web/src/domain/__tests__/customers.use-cases.test.tsx
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
18
web/src/domain/__tests__/hookTestUtils.tsx
Normal file
18
web/src/domain/__tests__/hookTestUtils.tsx
Normal file
|
|
@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
74
web/src/domain/__tests__/lineItems.use-cases.test.tsx
Normal file
74
web/src/domain/__tests__/lineItems.use-cases.test.tsx
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
126
web/src/domain/__tests__/proposals.use-cases.test.tsx
Normal file
126
web/src/domain/__tests__/proposals.use-cases.test.tsx
Normal file
|
|
@ -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<ProposalListItem> = {
|
||||
items: [],
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
totalCount: 0,
|
||||
} as unknown as PagedResponse<ProposalListItem>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue