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