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>
101 lines
3.9 KiB
TypeScript
101 lines
3.9 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';
|
|
import axios from 'axios';
|
|
|
|
vi.mock('axios');
|
|
|
|
import ProjectsView from '../../resources/js/views/ProjectsView.vue';
|
|
|
|
/**
|
|
* Инцидент 14.07.2026: клиент ЧЕТЫРЕ раза подряд пытался удалить проект из меню карточки
|
|
* и каждый раз получал молчание — сервер отвечал «Нельзя удалить проект: по нему есть
|
|
* сделки. Поставьте приём на паузу», а фронт ронял этот ответ в никуда (store.del без
|
|
* catch, глобального перехватчика нет). Человек так и ушёл, ничего не поняв.
|
|
*
|
|
* Теперь причина показывается прямо там, где он нажал, и предлагается выход — пауза.
|
|
*/
|
|
const BLOCKED_MESSAGE = 'Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.';
|
|
|
|
const factory = () =>
|
|
mount(ProjectsView, {
|
|
global: {
|
|
plugins: [createVuetify()],
|
|
stubs: {
|
|
VDialog: {
|
|
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
|
props: ['modelValue'],
|
|
},
|
|
NewProjectDialog: true,
|
|
EditProjectDialog: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
function mockProjects() {
|
|
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
data: {
|
|
data: [
|
|
{
|
|
id: 46,
|
|
name: 'дентал',
|
|
signal_type: 'call',
|
|
signal_identifier: '79221364286',
|
|
daily_limit_target: 15,
|
|
delivered_today: 0,
|
|
is_active: false,
|
|
sync_status: 'ok',
|
|
},
|
|
],
|
|
meta: { total: 1, current_page: 1, per_page: 20 },
|
|
},
|
|
});
|
|
}
|
|
|
|
function mockDeleteBlocked() {
|
|
(axios.delete as unknown as ReturnType<typeof vi.fn>).mockRejectedValue({
|
|
response: { status: 422, data: { errors: { project: [BLOCKED_MESSAGE] } } },
|
|
});
|
|
}
|
|
|
|
describe('ProjectsView — удаление проекта со сделками', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia());
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('показывает причину отказа, а не молчит', async () => {
|
|
mockProjects();
|
|
mockDeleteBlocked();
|
|
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
|
|
wrapper.findComponent({ name: 'ProjectCard' }).vm.$emit('delete', { id: 46, name: 'дентал', is_active: false });
|
|
await flushPromises();
|
|
|
|
expect(wrapper.text()).toContain('по нему есть сделки');
|
|
});
|
|
|
|
it('предлагает выход — кнопку «Поставить на паузу»', async () => {
|
|
mockProjects();
|
|
mockDeleteBlocked();
|
|
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { data: {} } });
|
|
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
|
|
wrapper.findComponent({ name: 'ProjectCard' }).vm.$emit('delete', { id: 46, name: 'дентал', is_active: true });
|
|
await flushPromises();
|
|
|
|
const pauseBtn = wrapper.findAll('button').find((b) => b.text().includes('Поставить на паузу'));
|
|
expect(pauseBtn).toBeDefined();
|
|
|
|
await pauseBtn!.trigger('click');
|
|
await flushPromises();
|
|
|
|
expect(axios.patch).toHaveBeenCalledWith('/api/projects/46/toggle-active', { is_active: false });
|
|
});
|
|
});
|