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

66 lines
3.1 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
// Мокаем только сетевые вызовы; autopodborErrorMessage (разбор причины) — настоящая,
// именно её работу мы и проверяем.
vi.mock('../../resources/js/api/autopodbor', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/autopodbor')>()),
startStudyBatch: vi.fn(),
}));
import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
/**
* Инцидент 14.07.2026: клиент трижды нажал «Добавить в поле» и трижды увидел одинаковое
* «Не удалось добавить конкурента» — форма ловила ошибку голым catch {} и выбрасывала
* объяснение сервера в мусор. Человек не понял, что чинить, и бросил функцию совсем.
*
* Теперь форма обязана показать ПРИЧИНУ, присланную сервером.
*/
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field') };
}
function mountWs(nav: unknown) {
return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
describe('FieldWorkspaceScreen — «Добавить вручную» объясняет отказ', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('показывает причину от сервера, а не безликое «Не удалось»', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [] as never;
});
vi.spyOn(store, 'addFieldCompetitor').mockRejectedValue({
isAxiosError: true, // как настоящая ошибка axios
response: {
status: 422,
data: { errors: { 'elements.0.cards.0': ['Ссылка на карточку слишком длинная — максимум 2000 символов.'] } },
},
});
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
const vm = w.vm as unknown as {
addComp: { open: boolean; name: string; note: string; elements: unknown[] };
saveAdd: () => Promise<void>;
};
vm.addComp.name = 'Сиб Дент';
await vm.saveAdd();
expect(vm.addComp.note).toContain('слишком длинная');
expect(vm.addComp.note).not.toBe('Не удалось добавить конкурента.');
});
});