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