import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import ReportsView from '../../resources/js/views/ReportsView.vue'; import { REPORT_TYPES } from '../../resources/js/composables/mockReports'; import type { ApiReportJob, ListReportJobsResponse } from '../../resources/js/api/reports'; vi.mock('../../resources/js/api/reports', () => ({ listReportJobs: vi.fn(), createReportJob: vi.fn(), retryReportJob: vi.fn(), cancelReportJob: vi.fn(), deleteReportJob: vi.fn(), })); const reportsApi = await import('../../resources/js/api/reports'); beforeEach(() => { vi.clearAllMocks(); (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [], total: 0, limit: 50, offset: 0, counts: { pending: 0, processing: 0, done: 0, failed: 0 }, quota: { active: 0, max_active: 3 }, } satisfies ListReportJobsResponse); }); function makeApiJob(overrides: Partial = {}): ApiReportJob { return { id: 1, type: 'deals_export', parameters: { format: 'csv', date_from: '2026-04-01', date_to: '2026-04-30' }, status: 'done', file_path: 'reports/1/1.csv', file_size: 1024, generation_seconds: 2, error_message: null, created_at: new Date(Date.now() - 60_000).toISOString(), finished_at: new Date(Date.now() - 30_000).toISOString(), expires_at: new Date(Date.now() + 30 * 86_400_000).toISOString(), is_expired: false, retry_count: 0, retry_max: 3, ...overrides, }; } const mountView = async () => { const wrapper = mount(ReportsView, { global: { plugins: [createVuetify()] }, }); await flushPromises(); return wrapper; }; describe('ReportsView.vue (API integration)', () => { it('монтируется и содержит заголовок «Отчёты»', async () => { const wrapper = await mountView(); expect(wrapper.find('h1').text()).toBe('Отчёты'); }); it('вызывает listReportJobs на mount', async () => { await mountView(); expect(reportsApi.listReportJobs).toHaveBeenCalledTimes(1); }); it('содержит 4 карточки типа отчёта', async () => { const wrapper = await mountView(); const cards = wrapper.findAll('.tc-card'); expect(cards).toHaveLength(REPORT_TYPES.length); }); it('по умолчанию активна карточка «Сделки · детально»', async () => { const wrapper = await mountView(); const dealsCard = wrapper.findAll('.tc-card').find((c) => c.text().includes('Сделки · детально')); expect(dealsCard?.classes()).toContain('active'); }); it('содержит 4 кнопки формата (CSV/XLSX/JSON/PDF)', async () => { const wrapper = await mountView(); const text = wrapper.text(); ['CSV', 'XLSX', 'JSON', 'PDF'].forEach((f) => expect(text).toContain(f)); }); it('quota-banner отражает API-данные (0 из 3)', async () => { const wrapper = await mountView(); const text = wrapper.text(); expect(text).toContain('0 из 3'); expect(text).toContain('3 попыток retry'); expect(text).toContain('7 дней'); }); it('пустой список → empty-state «Нет отчётов»', async () => { const wrapper = await mountView(); expect(wrapper.text()).toContain('Нет отчётов'); }); it('done-job отображается с «Готов» + Скачать-кнопкой', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ status: 'done' })], total: 1, limit: 50, offset: 0, counts: { pending: 0, processing: 0, done: 1, failed: 0 }, quota: { active: 0, max_active: 3 }, } satisfies ListReportJobsResponse); const wrapper = await mountView(); expect(wrapper.text()).toContain('Готов'); expect(wrapper.find('[aria-label="Скачать"]').exists()).toBe(true); }); it('failed-job отображается с «Ошибка» + Повторить-кнопкой при retry_count<3', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ status: 'failed', error_message: 'S3 timeout', retry_count: 1 })], total: 1, limit: 50, offset: 0, counts: { pending: 0, processing: 0, done: 0, failed: 1 }, quota: { active: 0, max_active: 3 }, } satisfies ListReportJobsResponse); const wrapper = await mountView(); const text = wrapper.text(); expect(text).toContain('Ошибка'); expect(text).toContain('S3 timeout'); expect(wrapper.find('[aria-label="Повторить"]').exists()).toBe(true); }); it('failed-job с retry_count=3 НЕ показывает Повторить (max attempts)', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ status: 'failed', error_message: 'X', retry_count: 3 })], total: 1, limit: 50, offset: 0, counts: { pending: 0, processing: 0, done: 0, failed: 1 }, quota: { active: 0, max_active: 3 }, } satisfies ListReportJobsResponse); const wrapper = await mountView(); expect(wrapper.find('[aria-label="Повторить"]').exists()).toBe(false); }); it('queued-job (=pending) отображается с «В очереди» + Отменить', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ status: 'pending', file_path: null, file_size: null })], total: 1, limit: 50, offset: 0, counts: { pending: 1, processing: 0, done: 0, failed: 0 }, quota: { active: 1, max_active: 3 }, } satisfies ListReportJobsResponse); const wrapper = await mountView(); expect(wrapper.text()).toContain('В очереди'); expect(wrapper.find('[aria-label="Отменить"]').exists()).toBe(true); }); it('Submit вызывает createReportJob с правильным payload + reload', async () => { (reportsApi.createReportJob as ReturnType).mockResolvedValue(makeApiJob()); const wrapper = await mountView(); await wrapper.find('[data-testid="submit-btn"]').trigger('click'); await flushPromises(); expect(reportsApi.createReportJob).toHaveBeenCalledOnce(); const payload = (reportsApi.createReportJob as ReturnType).mock.calls[0][0]; expect(payload.type).toBe('deals_export'); expect(payload.format).toBe('csv'); expect(payload.parameters).toHaveProperty('date_from'); expect(payload.parameters).toHaveProperty('date_to'); // Reload после submit (1 на mount + 1 после). expect(reportsApi.listReportJobs).toHaveBeenCalledTimes(2); }); it('Submit при validation error → submit-error-alert', async () => { (reportsApi.createReportJob as ReturnType).mockRejectedValue({ isAxiosError: true, response: { status: 422, data: { errors: { _quota: ['Лимит исчерпан'] } }, }, }); const wrapper = await mountView(); await wrapper.find('[data-testid="submit-btn"]').trigger('click'); await flushPromises(); expect(wrapper.find('[data-testid="submit-error-alert"]').exists()).toBe(true); }); it('Submit-btn disabled когда квота заполнена (3/3)', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [], total: 0, limit: 50, offset: 0, counts: { pending: 3, processing: 0, done: 0, failed: 0 }, quota: { active: 3, max_active: 3 }, } satisfies ListReportJobsResponse); const wrapper = await mountView(); const btn = wrapper.find('[data-testid="submit-btn"]'); expect(btn.attributes('disabled')).toBeDefined(); }); it('Reset возвращает форму к defaults', async () => { const wrapper = await mountView(); // Переключаем на managers const managersCard = wrapper.findAll('.tc-card').find((c) => c.text().includes('Менеджеры')); await managersCard!.trigger('click'); await wrapper.vm.$nextTick(); expect(managersCard!.classes()).toContain('active'); // Reset await wrapper.find('[data-testid="reset-btn"]').trigger('click'); await wrapper.vm.$nextTick(); const dealsCard = wrapper.findAll('.tc-card').find((c) => c.text().includes('Сделки · детально')); expect(dealsCard!.classes()).toContain('active'); }); it('Reload-btn триггерит manual reload', async () => { const wrapper = await mountView(); await wrapper.find('[data-testid="reload-btn"]').trigger('click'); await flushPromises(); expect(reportsApi.listReportJobs).toHaveBeenCalledTimes(2); }); it('listReportJobs reject → fetch-error-alert', async () => { (reportsApi.listReportJobs as ReturnType).mockRejectedValue({ isAxiosError: true, response: { status: 500, data: { message: 'Backend сломан' } }, }); const wrapper = await mountView(); expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true); }); it('Retry-btn вызывает retryReportJob + reload', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ id: 42, status: 'failed', error_message: 'X', retry_count: 0 })], total: 1, limit: 50, offset: 0, counts: { pending: 0, processing: 0, done: 0, failed: 1 }, quota: { active: 0, max_active: 3 }, } satisfies ListReportJobsResponse); (reportsApi.retryReportJob as ReturnType).mockResolvedValue(makeApiJob({ id: 99 })); const wrapper = await mountView(); await wrapper.find('[data-testid="retry-42"]').trigger('click'); await flushPromises(); expect(reportsApi.retryReportJob).toHaveBeenCalledWith(42); expect(reportsApi.listReportJobs).toHaveBeenCalledTimes(2); }); it('Cancel-btn вызывает cancelReportJob + reload', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ id: 7, status: 'pending', file_path: null, file_size: null })], total: 1, limit: 50, offset: 0, counts: { pending: 1, processing: 0, done: 0, failed: 0 }, quota: { active: 1, max_active: 3 }, } satisfies ListReportJobsResponse); (reportsApi.cancelReportJob as ReturnType).mockResolvedValue(makeApiJob({ id: 7 })); const wrapper = await mountView(); await wrapper.find('[data-testid="cancel-7"]').trigger('click'); await flushPromises(); expect(reportsApi.cancelReportJob).toHaveBeenCalledWith(7); }); it('Delete-btn открывает confirm-dialog + при подтверждении вызывает deleteReportJob', async () => { (reportsApi.listReportJobs as ReturnType).mockResolvedValue({ jobs: [makeApiJob({ id: 5, status: 'done' })], total: 1, limit: 50, offset: 0, counts: { pending: 0, processing: 0, done: 1, failed: 0 }, quota: { active: 0, max_active: 3 }, } satisfies ListReportJobsResponse); (reportsApi.deleteReportJob as ReturnType).mockResolvedValue(undefined); const wrapper = await mountView(); await wrapper.find('[data-testid="delete-5"]').trigger('click'); await wrapper.vm.$nextTick(); // Dialog имеет teleport-target вне wrapper'а; ищем глобально через document. const confirmBtn = document.querySelector('[data-testid="delete-confirm-btn"]') as HTMLElement | null; expect(confirmBtn).not.toBeNull(); confirmBtn!.click(); await flushPromises(); expect(reportsApi.deleteReportJob).toHaveBeenCalledWith(5); }); });