Files
portal/app/tests/Frontend/FieldCompetitorScreenBulk.spec.ts
T
Дмитрий 9ff7bc8fa0 feat(биллинг-фронт): баланс на запуске — сообщения в рублях, метка «не запущен», группы
Фронт для правила «баланс блокирует запуск, а не создание»:
- ProjectLimitOverloadDialog переформулирован в «Проект не запущен — пополните
  ~X ₽ или уменьшите объём» (сумма в рублях topup_rub).
- NewProjectDialog: создание всегда успешно; при launch.deferred показывает
  сообщение (не блок). Update-лимита 409 → тот же диалог.
- Возобновление (toggle-active) ловит 409 balance_insufficient (стор не
  переключает is_active), показывает диалог; постоянная метка «Не запущен —
  не хватает баланса» на карточке/в панели по preflight_blocked_at/balance_blocked.
- BulkActionsBar: сводка «Запущено N, отложено M — не хватает баланса» на resume.
- Автоподбор: CreateScreen показывает сводку запуска (рубли); FieldCompetitor
  массовые pause/resume через общий POST /api/projects/bulk (наследует BULK_MAX,
  слепок-защиту, балансовый гейт).
- ProjectResource отдаёт preflight_blocked_at.

Новые/обновлённые фронт-тесты зелёные. Пред-существующий красный baseline
фронт-набора (ErrorView/Settings/Legal/… + ProjectsView v-show) не трогался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:04:16 +03:00

178 lines
6.8 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, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import axios from 'axios';
vi.mock('axios');
vi.mock('../../resources/js/api/autopodbor');
import * as api from '../../resources/js/api/autopodbor';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue';
const makeNav = (competitorId = 10) => ({
go: vi.fn(),
ctx: { competitorId, editProjectId: null, selectedSourceIds: [] },
screen: { value: 'field-competitor' },
});
// Фабрика источника с проектом
const makeSource = (id: number, projectId: number, isActive: boolean) => ({
id,
competitor_id: 10,
signal_type: 'site' as const,
identifier: `site-${id}.ru`,
phone_kind: null,
phone_type: null,
box: 'field' as const,
provenance_url: null,
provenance_label: null,
created_project_id: projectId,
project: {
id: projectId,
name: `Проект ${id}`,
signal_identifier: `site-${id}.ru`,
is_active: isActive,
paused_at: null,
preflight_blocked_at: null,
daily_limit_target: 5,
delivered_in_month: 10,
delivery_days_mask: 127,
regions: [16],
},
});
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 2, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
});
describe('FieldCompetitorScreen — bulk actions via /api/projects/bulk (Task 17b)', () => {
it('bulkWork("pause") шлёт POST /api/projects/bulk с project_id выбранных источников', async () => {
const nav = makeNav();
// Настраиваем store с двумя источниками с проектами
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
sources: [makeSource(1, 101, true), makeSource(2, 102, true)],
});
const store = useAutopodborStore();
await store.loadCompetitor(10);
const wrapper = mount(FieldCompetitorScreen, {
global: {
provide: { autopodborNav: nav },
stubs: { Transition: false },
},
});
// Ждём onMounted reload
await new Promise((r) => setTimeout(r, 50));
// Выбираем оба источника
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selected = [1, 2];
await wrapper.vm.$nextTick();
// Вызываем bulkWork('pause') напрямую через exposed
await vm.bulkWork('pause');
await new Promise((r) => setTimeout(r, 30));
// Должен быть вызван POST /api/projects/bulk с ids проектов [101, 102]
expect(vi.mocked(axios.post)).toHaveBeenCalledWith(
'/api/projects/bulk',
expect.objectContaining({
action: 'pause',
ids: expect.arrayContaining([101, 102]),
}),
);
});
it('bulkWork("resume") шлёт POST /api/projects/bulk с project_id', async () => {
const nav = makeNav();
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
sources: [makeSource(1, 101, false), makeSource(2, 102, false)],
});
const store = useAutopodborStore();
await store.loadCompetitor(10);
const wrapper = mount(FieldCompetitorScreen, {
global: {
provide: { autopodborNav: nav },
stubs: { Transition: false },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selected = [1, 2];
await wrapper.vm.$nextTick();
await vm.bulkWork('resume');
await new Promise((r) => setTimeout(r, 30));
expect(vi.mocked(axios.post)).toHaveBeenCalledWith(
'/api/projects/bulk',
expect.objectContaining({
action: 'resume',
ids: expect.arrayContaining([101, 102]),
}),
);
});
it('bulkWork пропускает источники без проекта', async () => {
const nav = makeNav();
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
sources: [
makeSource(1, 101, true),
// Источник без проекта
{
id: 2, competitor_id: 10, signal_type: 'site' as const, identifier: 'b.ru',
phone_kind: null, phone_type: null, box: 'field' as const,
provenance_url: null, provenance_label: null, created_project_id: null,
project: null,
},
],
});
const store = useAutopodborStore();
await store.loadCompetitor(10);
const wrapper = mount(FieldCompetitorScreen, {
global: {
provide: { autopodborNav: nav },
stubs: { Transition: false },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selected = [1, 2];
await wrapper.vm.$nextTick();
await vm.bulkWork('pause');
await new Promise((r) => setTimeout(r, 30));
// Должен отправить bulk только с id проекта источника 1 (101), источник 2 без проекта пропущен
expect(vi.mocked(axios.post)).toHaveBeenCalledWith(
'/api/projects/bulk',
expect.objectContaining({
action: 'pause',
ids: [101],
}),
);
});
});