import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import { createPinia, setActivePinia } from 'pinia'; import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue'; import { MOCK_DEALS } from '../../resources/js/composables/mockDeals'; import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals'; vi.mock('../../resources/js/api/deals', async (importOriginal) => { const orig = await importOriginal(); return { ...orig, getDeal: vi.fn(), updateDeal: vi.fn(), }; }); const dealsApi = await import('../../resources/js/api/deals'); beforeEach(() => { vi.clearAllMocks(); setActivePinia(createPinia()); }); const factory = (props: { tenantId?: number }) => mount(DealDetailBody, { props: { deal: MOCK_DEALS[0], ...props }, global: { plugins: [createVuetify()], }, }); function makeApiEvent(overrides: Partial = {}): ApiDealEvent { return { id: 100, event: 'deal.created', context: { source: 'webhook' }, created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), actor: null, ...overrides, }; } describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => { it('БЕЗ tenantId — getDeal не вызывается, events пуст (I3)', async () => { const wrapper = factory({}); await flushPromises(); expect(dealsApi.getDeal).not.toHaveBeenCalled(); const items = wrapper.findAll('.timeline-item'); expect(items).toHaveLength(0); }); it('С tenantId — getDeal вызывается, events заменяются на API', async () => { const apiResponse: GetDealResponse = { deal: { id: MOCK_DEALS[0].id, tenant_id: 1, project_id: 5, project_name: 'Окна Москва', phone: '+7 (999) 100-00-01', contact_name: 'Anna', comment: null, city: null, project_signal_type: null, status: 'new', manager_id: null, manager_name: null, manager_initials: null, received_at: new Date().toISOString(), assigned_at: null, }, events: [ makeApiEvent({ id: 1, event: 'deal.created', context: { source: 'webhook' } }), makeApiEvent({ id: 2, event: 'deal.status_changed', context: { from: 'new', to: 'won' }, actor: { id: 1, name: 'Иван П.', initials: 'ИП' }, }), ], }; vi.mocked(dealsApi.getDeal).mockResolvedValueOnce(apiResponse); const wrapper = factory({ tenantId: 1 }); await flushPromises(); expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1); const items = wrapper.findAll('.timeline-item'); expect(items).toHaveLength(2); // status_changed event имеет detail "new → won". expect(wrapper.text()).toContain('new → won'); }); it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => { vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500')); const wrapper = factory({ tenantId: 1 }); await flushPromises(); const vm = wrapper.vm as unknown as { eventsFetchError: boolean }; expect(vm.eventsFetchError).toBe(true); expect(wrapper.find('[data-testid="events-fetch-error-alert"]').exists()).toBe(true); // I3: нет mock-fallback — events пуст. const items = wrapper.findAll('.timeline-item'); expect(items).toHaveLength(0); }); it('saveComment вызывает updateDeal + toast success + reload events', async () => { const apiResponse: GetDealResponse = { deal: { id: MOCK_DEALS[0].id, tenant_id: 1, project_id: 5, project_name: 'Окна', phone: '+7 (999) 100-00-01', contact_name: 'Anna', comment: 'old', city: null, project_signal_type: null, status: 'new', manager_id: null, manager_name: null, manager_initials: null, received_at: new Date().toISOString(), assigned_at: null, }, events: [], }; vi.mocked(dealsApi.getDeal).mockResolvedValue(apiResponse); vi.mocked(dealsApi.updateDeal).mockResolvedValueOnce({ ...apiResponse.deal, comment: 'новая заметка', }); const wrapper = factory({ tenantId: 1 }); await flushPromises(); const vm = wrapper.vm as unknown as { commentDraft: string; saveComment: () => Promise; commentToastOpen: boolean; commentToastText: string; commentSaveError: boolean; }; expect(vm.commentDraft).toBe('old'); // загружено из getDeal vm.commentDraft = 'новая заметка'; await vm.saveComment(); await flushPromises(); expect(dealsApi.updateDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, { tenant_id: 1, comment: 'новая заметка', }); expect(vm.commentToastOpen).toBe(true); expect(vm.commentToastText).toContain('сохранён'); expect(vm.commentSaveError).toBe(false); // reload events после save — getDeal вызвался ещё раз. expect(dealsApi.getDeal).toHaveBeenCalledTimes(2); }); it('saveComment reject → toast warning + commentSaveError=true', async () => { vi.mocked(dealsApi.getDeal).mockResolvedValueOnce({ deal: { id: MOCK_DEALS[0].id, tenant_id: 1, project_id: 5, project_name: null, phone: '+7 (999)', contact_name: null, comment: null, city: null, project_signal_type: null, status: 'new', manager_id: null, manager_name: null, manager_initials: null, received_at: null, assigned_at: null, }, events: [], }); vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500')); const wrapper = factory({ tenantId: 1 }); await flushPromises(); const vm = wrapper.vm as unknown as { commentDraft: string; saveComment: () => Promise; commentSaveError: boolean; commentToastText: string; }; vm.commentDraft = 'новый'; await vm.saveComment(); await flushPromises(); expect(vm.commentSaveError).toBe(true); expect(vm.commentToastText).toContain('Не удалось'); }); it('comment-section не показывается без tenantId (read-only mode)', async () => { const wrapper = factory({}); await flushPromises(); expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false); }); });