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>
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
/**
|
||
* Task 13: при ответе создания с launch.deferred=1
|
||
* показывается диалог перегрузки (проект считается созданным).
|
||
*
|
||
* Контракт POST /api/projects → 201:
|
||
* { data: {...}, launch: { launched: 0|1, deferred: 0|1, balance: OverloadPayload | null } }
|
||
*
|
||
* Если launch.deferred === 1:
|
||
* - форма закрывается (emit saved)
|
||
* - открывается ProjectLimitOverloadDialog с payload = launch.balance
|
||
*
|
||
* Если launch.deferred === 0: обычный успех, диалога нет.
|
||
*/
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import { createVuetify } from 'vuetify';
|
||
|
||
vi.mock('../../resources/js/api/client', () => ({
|
||
apiClient: {
|
||
post: vi.fn(),
|
||
patch: vi.fn().mockResolvedValue({ data: {} }),
|
||
},
|
||
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
|
||
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
|
||
}));
|
||
|
||
vi.mock('../../resources/js/api/requisites', () => ({
|
||
getRequisites: vi.fn().mockResolvedValue({
|
||
subject_type: 'individual',
|
||
contact_name: 'Иван',
|
||
contact_phone: '+79161234567',
|
||
inn: null,
|
||
}),
|
||
updateRequisites: vi.fn().mockResolvedValue({}),
|
||
}));
|
||
|
||
// vue-router: stub для ProjectLimitOverloadDialog.goTopup
|
||
vi.mock('vue-router', () => ({
|
||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||
}));
|
||
|
||
import { apiClient } from '../../resources/js/api/client';
|
||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||
import type { Project } from '../../resources/js/stores/projectsStore';
|
||
|
||
const factory = (
|
||
props: { modelValue: boolean; mode?: 'create' | 'edit'; project?: Project | null } = {
|
||
modelValue: true,
|
||
mode: 'create',
|
||
},
|
||
) =>
|
||
mount(NewProjectDialog, {
|
||
props,
|
||
global: {
|
||
plugins: [createVuetify()],
|
||
stubs: {
|
||
VDialog: {
|
||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||
props: ['modelValue'],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe('NewProjectDialog — launch.deferred (Task 13)', () => {
|
||
it('при launch.deferred=1 открывается диалог перегрузки и эмитится saved', async () => {
|
||
// Бэкенд вернул 201 с deferred=1
|
||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
data: {
|
||
data: { id: 42, name: 'Тест' },
|
||
launch: {
|
||
launched: 0,
|
||
deferred: 1,
|
||
balance: {
|
||
current_balance_rub: '500.00',
|
||
current_capacity_leads: 15,
|
||
would_be_required_leads: 50,
|
||
deficit_leads: 35,
|
||
topup_rub: '1050.00',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const wrapper = factory();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
confirmVsyaRf: () => void;
|
||
form: { name: string; signal_identifier: string };
|
||
submit: () => Promise<void>;
|
||
};
|
||
vm.confirmVsyaRf();
|
||
vm.form.name = 'Тест';
|
||
vm.form.signal_identifier = 'test.ru';
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
// Диалог перегрузки должен быть открыт
|
||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||
expect(overload.exists()).toBe(true);
|
||
|
||
// Payload пробрасывается — показывается сумма пополнения
|
||
expect(overload.text()).toContain('1050.00');
|
||
|
||
// saved должен был эмититься (форма считается созданной)
|
||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||
});
|
||
|
||
it('при launch.deferred=0 диалога перегрузки нет, эмитится saved', async () => {
|
||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
data: {
|
||
data: { id: 43, name: 'Тест2' },
|
||
launch: {
|
||
launched: 1,
|
||
deferred: 0,
|
||
balance: null,
|
||
},
|
||
},
|
||
});
|
||
|
||
const wrapper = factory();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
confirmVsyaRf: () => void;
|
||
form: { name: string; signal_identifier: string };
|
||
submit: () => Promise<void>;
|
||
};
|
||
vm.confirmVsyaRf();
|
||
vm.form.name = 'Тест2';
|
||
vm.form.signal_identifier = 'test2.ru';
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
// Диалога перегрузки быть не должно
|
||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||
expect(overload.exists()).toBe(false);
|
||
|
||
// saved эмититься должен
|
||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||
});
|
||
|
||
it('при launch.deferred=1 баннер не показывает ошибку в форме', async () => {
|
||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
data: {
|
||
data: { id: 44 },
|
||
launch: {
|
||
launched: 0,
|
||
deferred: 1,
|
||
balance: {
|
||
current_balance_rub: '0.00',
|
||
current_capacity_leads: 0,
|
||
would_be_required_leads: 50,
|
||
deficit_leads: 50,
|
||
topup_rub: '1500.00',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const wrapper = factory();
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as {
|
||
confirmVsyaRf: () => void;
|
||
form: { name: string; signal_identifier: string };
|
||
submit: () => Promise<void>;
|
||
};
|
||
vm.confirmVsyaRf();
|
||
vm.form.name = 'Тест3';
|
||
vm.form.signal_identifier = 'test3.ru';
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
// В форме нет общей ошибки
|
||
const errorAlert = wrapper.find('.dialog-stub .v-alert--type-error');
|
||
expect(errorAlert.exists()).toBe(false);
|
||
});
|
||
|
||
it('update (edit) 409 balance_insufficient читает payload из err.response.data.balance', async () => {
|
||
// В edit-режиме PATCH всё ещё может вернуть 409 — payload вложен в .balance
|
||
(apiClient.patch as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||
response: {
|
||
status: 409,
|
||
data: {
|
||
error: 'balance_insufficient',
|
||
balance: {
|
||
current_balance_rub: '200.00',
|
||
current_capacity_leads: 6,
|
||
would_be_required_leads: 80,
|
||
deficit_leads: 74,
|
||
topup_rub: '2220.00',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const project = {
|
||
id: 5,
|
||
name: 'Проект',
|
||
signal_type: 'site' as const,
|
||
signal_identifier: 'edit.ru',
|
||
daily_limit_target: 10,
|
||
delivered_today: 0,
|
||
is_active: true,
|
||
regions: [77],
|
||
delivery_days_mask: 127,
|
||
sync_status: 'ok' as const,
|
||
region_mask: 0,
|
||
region_mode: 'include',
|
||
} as unknown as Project;
|
||
|
||
const wrapper = factory({ modelValue: true, mode: 'edit', project });
|
||
await flushPromises();
|
||
|
||
const vm = wrapper.vm as unknown as { submit: () => Promise<void> };
|
||
await vm.submit();
|
||
await flushPromises();
|
||
|
||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||
expect(overload.exists()).toBe(true);
|
||
// Проверяем что payload из .balance дошёл (сумма 2220)
|
||
expect(overload.text()).toContain('2220');
|
||
});
|
||
});
|