Files
portal/app/tests/Frontend/ReportsView.spec.ts
T
Дмитрий e0ffe7e686 phase2(reports-stage4): frontend integration ReportsView (replace mock)
- api/reports.ts: типизированные axios-helpers (listReportJobs/createReportJob/
  retryReportJob/cancelReportJob/deleteReportJob) + ApiReportJob/Status/Format/
  Counts/Quota interfaces. ensureCsrfCookie на mutating-вызовах.
- composables/reportsMapper.ts: mapApiReportJob (API → UI mock format с конверсией
  pending→queued / processing→running). title строится на frontend'е (тип + period
  с RU-месяцами «апр 2026» или диапазон «мар 2026 — апр 2026»). sizeText форматирует
  bytes (B/KB/MB). timeText зависит от status (в очереди / в работе · Nс / N мин назад).
  uiTypeToApi (deals → deals_export и т.д.).
- ReportsView.vue полностью переписан под API:
  - onMounted → loadJobs (replace MOCK_JOBS на data из listReportJobs).
  - usePolling 30 сек (фоновый авто-refresh).
  - Submit → createReportJob → reload + success-alert + error-alert (validation+
    общие ошибки извлекаются через extractValidationErrors/extractErrorMessage).
  - canSubmit computed: disable если квота заполнена (active >= max).
  - Reset-btn возвращает форму к defaults.
  - Reload-btn (manual fast-path).
  - Retry/Cancel/Download/Delete-кнопки → соответствующие API-вызовы;
    Delete через v-dialog persistent confirm.
  - fetch-error-alert на listReportJobs reject.
  - Empty-state «Нет отчётов» когда jobs.length=0.
  - canRetry проверяет retry_count<3 (max attempts CTO-6).
- Vitest +24 (всего 393/393, +24 от 369):
  - reportsMapper.spec.ts +14: status mapping (pending/processing/done/failed) /
    title (один месяц / диапазон) / format / sizeText (B/KB/MB/null) / attempt /
    error / timeText (pending / processing / done «10 мин назад» / «только что») /
    uiTypeToApi 4 slug'а / progress=50 для running.
  - ReportsView.spec.ts переписан с MOCK_JOBS на vi.mock('api/reports') +12:
    mount + listReportJobs called on mount / 4 type cards / default Сделки active /
    4 формата / quota-banner из API / empty-state / done с Готов+Скачать /
    failed с Ошибка+Повторить / failed retry_count=3 НЕ показывает Повторить /
    pending с Отменить / Submit вызывает createReportJob+reload / Submit error →
    submit-error-alert / Submit-btn disabled при квоте 3/3 / Reset / Reload-btn /
    fetch-error-alert / Retry-btn / Cancel-btn / Delete confirm-dialog +
    deleteReportJob.

Этап 4/4 эпика Reports backend ЗАКРЫТ. Эпик закрыт целиком.

Backend: 1 type (deals_export) × 4 формата (CSV/XLSX/JSON/PDF-stub).
Этап 2b (3 оставшихся типа: managers_summary/sources_summary/billing_summary)
— расширение через добавление 3 новых Provider-классов без изменений в архитектуре,
вынесено в Post-MVP backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:49:55 +03:00

285 lines
13 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,
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);
});
});