Files
portal/app/tests/Frontend/ReportsView.spec.ts
T
Дмитрий c687694f43 fix(portal): убраны машинные слова, которые видит клиент
В «Отчётах» клиенту показывали «retry-failed для owner отчёта», «3 попыток
retry» и «Готовится асинхронно». В «Конкурентном поле» подсказка звала
элементы «подкомпаниями / ЖК» (чужая ниша). Переписано по-человечески:
перезапустить до 3 раз за 7 дней, готовится в фоне, элементы — филиалы или
отдельные бренды.

Тест на баннер отчётов теперь запрещает слово «retry» в интерфейсе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:36:32 +03:00

329 lines
15 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 ReportRequestForm from '../../resources/js/components/reports/ReportRequestForm.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');
// Клиенту показываем по-русски: «retry» и «owner» — слова из машинного мира.
expect(text).toContain('перезапустить');
expect(text).toContain('7 дней');
expect(text).not.toContain('retry');
});
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('отмена задачи сбрасывает зелёный «отчёт создан» (submitSuccess)', 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.createReportJob as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiJob());
(reportsApi.cancelReportJob as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiJob({ id: 7 }));
const wrapper = await mountView();
// Создаём отчёт → зелёный «успех» загорается.
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
await flushPromises();
const form = wrapper.findComponent(ReportRequestForm);
expect(form.props('submitSuccess')).toBe(true);
// Отменяем задачу → зелёный «успех» должен погаснуть (не висеть после отмены).
await wrapper.find('[data-testid="cancel-7"]').trigger('click');
await flushPromises();
expect(form.props('submitSuccess')).toBe(false);
});
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);
});
});