Files
portal/app/tests/Frontend/ReportsView.spec.ts
T

285 lines
13 KiB
TypeScript
Raw Normal View History

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<typeof vi.fn>).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> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});