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>
85 lines
4.3 KiB
PHP
85 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AutopodborCompetitor;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
/**
|
|
* Инцидент 14.07.2026: клиент трижды подряд получил отказ в «Добавить конкурента вручную»
|
|
* и бросил функцию совсем. Ссылки на карточки 2ГИС/Яндекс.Карт, скопированные прямо из
|
|
* адресной строки, легко перевалят за 500 символов (хвост параметров sctx/ll/utm) —
|
|
* а у нас стоял лимит max:500 на каждую ссылку.
|
|
*
|
|
* Решение: лимит щедрый (2000), а ссылку чистим сами — обрезаем хвост параметров,
|
|
* который для поиска источников бесполезен.
|
|
*/
|
|
it('принимает длинную ссылку на карточку, скопированную из адресной строки', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
|
|
|
$longCard = 'https://yandex.ru/maps/org/sib_dent/1234567890/?ll=92.852572%2C56.010569&z=17&sctx='.str_repeat('AbCd0123', 90);
|
|
expect(strlen($longCard))->toBeGreaterThan(500); // именно такие ссылки и вставляют люди
|
|
|
|
$resp = $this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', [
|
|
'name' => 'Сиб Дент',
|
|
'elements' => [
|
|
['name' => 'Ленина 111', 'sites' => ['https://sib-dental.ru/'], 'cards' => [$longCard]],
|
|
],
|
|
])->assertStatus(201);
|
|
|
|
$comp = AutopodborCompetitor::find($resp->json('data.id'));
|
|
expect($comp->directory_urls)->toHaveCount(1)
|
|
// Хвост параметров отрезан — храним чистый адрес карточки.
|
|
->and($comp->directory_urls[0])->toBe('https://yandex.ru/maps/org/sib_dent/1234567890/');
|
|
});
|
|
|
|
/**
|
|
* Колонка site_url — VARCHAR(255) (db/schema.sql:1406). Валидацию мы подняли до 2000, чтобы
|
|
* принимать длинные ссылки, — значит перед записью ОБЯЗАНЫ привести к домену, иначе вместо
|
|
* старого «мягкого» отказа клиент получит крэш базы (22001) и невнятную ошибку.
|
|
* Поймано валидатором до выката 14.07.2026.
|
|
*/
|
|
it('правка карточки: длинный адрес сайта не роняет базу — приводится к домену', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
|
|
|
$comp = AutopodborCompetitor::create([
|
|
'tenant_id' => $tenant->id, 'name' => 'Сиб Дент', 'origin' => 'manual', 'box' => 'field',
|
|
'elements' => [], 'directory_urls' => [], 'dedup_key' => 'sib-dent|'.uniqid(),
|
|
]);
|
|
|
|
$longSite = 'https://www.sib-dental.ru/uslugi/implantaciya/?utm_campaign='.str_repeat('x', 400);
|
|
|
|
$this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}", [
|
|
'elements' => [
|
|
['name' => '', 'sites' => [$longSite], 'cards' => []],
|
|
],
|
|
])->assertOk();
|
|
|
|
expect($comp->fresh()->site_url)->toBe('sib-dental.ru');
|
|
});
|
|
|
|
it('принимает сайт, введённый целиком с http:// и путём — приводит к домену', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
|
|
|
$resp = $this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', [
|
|
'name' => 'Сиб Дент',
|
|
'elements' => [
|
|
['name' => '', 'sites' => ['https://www.sib-dental.ru/uslugi/implantaciya/'], 'cards' => []],
|
|
],
|
|
])->assertStatus(201);
|
|
|
|
$comp = AutopodborCompetitor::find($resp->json('data.id'));
|
|
expect($comp->site_url)->toBe('sib-dental.ru');
|
|
});
|