9ff7bc8fa0
Фронт для правила «баланс блокирует запуск, а не создание»: - 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>
178 lines
6.8 KiB
TypeScript
178 lines
6.8 KiB
TypeScript
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],
|
||
}),
|
||
);
|
||
});
|
||
});
|