mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-06 14:42:01 +00:00
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
|
|
// Line-item persistence flows through useSaveProposalWorkspace (admin
|
||
|
|
// domain) — the live workspace save path: update proposal, bulk-replace line
|
||
|
|
// items, broad-invalidate every cached view. 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 { lineItemsKeys } from '../lineItems/use-cases';
|
||
|
|
import { useSaveProposalWorkspace } from '../admin/use-cases';
|
||
|
|
import { proposalsKeys } from '../proposals/use-cases';
|
||
|
|
import { adminApi } from '../admin/api';
|
||
|
|
import { lineItemsApi } from '../lineItems/api';
|
||
|
|
import type { UpdateLineItemEntry } from '../lineItems/types';
|
||
|
|
|
||
|
|
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(),
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
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('useSaveProposalWorkspace', () => {
|
||
|
|
it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => {
|
||
|
|
vi.mocked(adminApi.updateProposal).mockResolvedValue(undefined);
|
||
|
|
vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]);
|
||
|
|
const { queryClient, wrapper } = createQueryHarness();
|
||
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||
|
|
|
||
|
|
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
|
||
|
|
result.current.mutate({ refinedScope: 'refined', lineItems: entries });
|
||
|
|
|
||
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||
|
|
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' });
|
||
|
|
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries);
|
||
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||
|
|
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
|
||
|
|
});
|
||
|
|
// Totals live on the proposal detail AND the cached queue/list views.
|
||
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||
|
|
queryKey: proposalsKeys.detail(PROPOSAL_ID),
|
||
|
|
});
|
||
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||
|
|
queryKey: proposalsKeys.lists(),
|
||
|
|
});
|
||
|
|
expect(toast.success).toHaveBeenCalledWith('Changes saved');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('toasts a save failure and skips invalidation', async () => {
|
||
|
|
vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict'));
|
||
|
|
const { queryClient, wrapper } = createQueryHarness();
|
||
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||
|
|
|
||
|
|
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
|
||
|
|
result.current.mutate({ refinedScope: 'refined', lineItems: entries });
|
||
|
|
|
||
|
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||
|
|
expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict');
|
||
|
|
expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled();
|
||
|
|
expect(invalidateSpy).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|