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

301 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
download_url: 'http://localhost/api/reports/jobs/1/file?tenant=1&signature=fake',
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('done-job: кнопка «Скачать» имеет href из download_url', async () => {
(reportsApi.listReportJobs as ReturnType<typeof vi.fn>).mockResolvedValue({
jobs: [makeApiJob({ status: 'done', download_url: 'http://localhost/dl?signature=xyz' })],
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();
const dl = wrapper.find('[aria-label="Скачать"]');
expect(dl.exists()).toBe(true);
expect(dl.attributes('href')).toBe('http://localhost/dl?signature=xyz');
});
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);
});
});