c0487ddaa5
Разбор живого клиента (стоматология, Красноярск, 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>
129 lines
4.8 KiB
TypeScript
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('Что-то не так с этим полем.');
|
|
});
|
|
});
|