301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
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);
|
||
});
|
||
});
|