Files
portal/app/tests/Frontend/NewProjectDialog.launch.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

233 lines
8.3 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.
/**
* 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');
});
});