Files
portal/app/tests/Frontend/NewProjectDialog.silentError.spec.ts
T
Дмитрий c0487ddaa5 fix(billing,ux): портал видит отмену платежа и перестаёт молчать в формах
Разбор живого клиента (стоматология, Красноярск, 14.07): 2 часа настраивал 26
проектов, получил 14 отказов в трёх формах и ушёл, не заплатив.

Деньги:
- отменённый шлюзом платёж больше не висит «ожидает» вечно: закрываем как failed
  с причиной (PaymentSettlementService — общий путь для webhook и крона);
- billing:reconcile-payments каждые 5 минут сам спрашивает шлюз про зависшие
  pending. Побочно страхует от ПОТЕРИ ДЕНЕГ: если webhook не дойдёт, оплаченный
  платёж всё равно зачислится;
- кабинет говорит правду: «Оплата не завершена» + «Оплатить снова» вместо
  «баланс обновится автоматически» (GET /api/billing/last-payment).

🔴 RLS-мина (поймана валидатором ДО выката): UPDATE при отмене шёл без
tenant-контекста → на проде тронул бы 0 строк, а портал рапортовал бы «отменено».
Тесты слепы (тестовая БД под postgres). Регресс-тест проверяет ПОРЯДОК:
SET LOCAL tenant ДО UPDATE. Тот же класс, что инциденты 07.07 и 12.07.

Формы (клиент бился и уходил):
- удаление проекта со сделками: причина показывается на месте + кнопка
  «Поставить на паузу» (раньше 422 улетал в никуда — 4 попытки впустую);
- создание проекта: ошибка по дням недели больше не молчит (у поля не было
  места для показа — 2 немых отказа);
- автоподбор «Добавить вручную»: показываем причину от сервера (был голый
  catch {}), длинные ссылки 2ГИС/Яндекс.Карт принимаются — трекинг-хвост срезаем
  сами. Воспроизведено тестом: именно длинная ссылка давала 3 отказа подряд.

Наблюдаемость: причины отказов пишутся в журнал (маршрут, tenant, ИМЕНА полей;
значений нет — 152-ФЗ). Уровень warning: на проде LOG_LEVEL=warning, info в
журнал не попадает вовсе. Робот-сверщик добавлен в реестр пульса.

Тесты: Pest 2475/2475, Vitest 1215/1215.
Выкачено на боевой 14.07.2026 ~13:00 МСК; сверка сразу закрыла 3 мёртвых платежа
(10 000 ₽, 5 000 ₽, 1 000 ₽).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:14:49 +03:00

129 lines
4.8 KiB
TypeScript

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('axios');
vi.mock('../../resources/js/api/client', () => ({
apiClient: {
post: vi.fn(),
patch: vi.fn(),
},
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: '+79221364286',
inn: null,
}),
updateRequisites: vi.fn().mockResolvedValue({}),
}));
import { apiClient } from '../../resources/js/api/client';
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
/**
* Инцидент 14.07.2026: клиент дважды нажал «Создать» — и НИЧЕГО не произошло. Сервер
* отказал по полю delivery_days_mask (человек снял все дни недели), а в форме это поле
* не привязано ни к какому месту показа ошибки: ответ раскладывался в errors и исчезал.
* Клиент тыкал вслепую 5 минут.
*
* Правило: любая причина отказа, которой негде показаться у поля, обязана появиться
* общим баннером. Форма не имеет права молчать.
*/
const factory = () =>
mount(NewProjectDialog, {
props: { modelValue: true, mode: 'create' as const },
global: {
plugins: [createVuetify(), createPinia()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
type Vm = {
submit: () => Promise<void>;
confirmVsyaRf: () => void;
form: { name: string; signal_identifier: string };
};
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe('NewProjectDialog — форма не молчит при отказе сервера', () => {
it('показывает причину отказа по дням недели', async () => {
vi.mocked(apiClient.post).mockRejectedValue({
response: {
status: 422,
data: {
message: 'Ошибка',
errors: { delivery_days_mask: ['Выберите хотя бы один день недели приёма.'] },
},
},
});
const wrapper = factory();
await flushPromises();
const vm = wrapper.vm as unknown as Vm;
vm.confirmVsyaRf();
vm.form.name = 'Сибдент тел';
vm.form.signal_identifier = 'sibdent.ru';
await vm.submit();
await flushPromises();
expect(wrapper.text()).toContain('Выберите хотя бы один день недели приёма.');
});
it('не отправляет проект без единого дня недели — объясняет сразу, не гоняя на сервер', async () => {
const wrapper = factory();
await flushPromises();
const vm = wrapper.vm as unknown as Vm & { selectedDays: number[] };
vm.confirmVsyaRf();
vm.form.name = 'Сибдент тел';
vm.form.signal_identifier = 'sibdent.ru';
vm.selectedDays.splice(0, vm.selectedDays.length); // человек снял все дни
await vm.submit();
await flushPromises();
expect(apiClient.post).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('день недели');
});
it('показывает причину отказа по полю, которого нет в форме', async () => {
vi.mocked(apiClient.post).mockRejectedValue({
response: {
status: 422,
data: {
message: 'Ошибка',
errors: { some_unknown_field: ['Что-то не так с этим полем.'] },
},
},
});
const wrapper = factory();
await flushPromises();
const vm = wrapper.vm as unknown as Vm;
vm.confirmVsyaRf();
vm.form.name = 'Сибдент тел';
vm.form.signal_identifier = 'sibdent.ru';
await vm.submit();
await flushPromises();
expect(wrapper.text()).toContain('Что-то не так с этим полем.');
});
});