82ffa17a24
Тест «денег НЕ двигает» падал только в полном прогоне: ждал ноль проводок по всей таблице, а видел 19. Поодиночке был зелёный - значит виноват не он и не код приёмника. Виновник найден и назван: тридцать файлов рекламной папки идут БЕЗ отката. Откат им не выдаётся и оптом - в tests/Pest.php строка RefreshDatabase закомментирована. Их проводки остаются в базе и достаются соседям. По всей папке Feature таких файлов 96 из 580. Датчик, которым нашёл: после прогона папки смотреть остаток в базе снаружи, через psql. База пересобирается в начале прогона, поэтому всё, что осталось после - вина этого прогона. Три папки, четыре минуты, ровно те же 19 строк, и они видны поимённо: пополнения и заморозки рекламных кампаний. Правка узкая: считаем проводки СВОЕГО клиента. Смысл проверки не изменился - приёмник не имеет права двигать деньги клиента, чьё сообщение он тронул. Приёмка вырезанием: заставил приёмник вернуть деньги этому же клиенту - тест покраснел; убрал - позеленел. Боевой код не тронут, git diff по app/app пуст. Прогон трёх папок: 989 из 989 зелёных, при том что 19 чужих строк в базе так и лежат - тест к соседям больше не чувствителен. Сама грязь НЕ убрана: файлы без отката - отдельная работа, делать её вслепую нельзя, части тестов записанные данные нужны по существу. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
11 KiB
PHP
238 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AdWalletTransaction;
|
|
use App\Models\ClientSmsCampaign;
|
|
use App\Models\ClientSmsMessage;
|
|
use App\Models\Tenant;
|
|
use App\Services\Sms\Providers\MtsSmsProvider;
|
|
use App\Services\Sms\SmsDeliveryState;
|
|
use App\Services\Sms\SmsRouter;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
/**
|
|
* Строка листа 5.2: МТС сам присылает отчёт о судьбе — приёмник ускоряет опрос.
|
|
*
|
|
* 🪤 SharesSupplierPdo обязателен: приёмник кросс-клиентский (у публичного адреса
|
|
* нет ни сессии, ни пометки клиента) и правит журнал СЛУЖЕБНЫМ соединением. Без
|
|
* общего PDO это соединение не видит незакоммиченных данных теста и находит НОЛЬ —
|
|
* прогон позеленел бы ВРУЩИ там, где мы ждём правку (урок В-95).
|
|
*
|
|
* 🔴 Форма письма от МТС нам НЕИЗВЕСТНА (журнал В-243) и здесь не выдумывается:
|
|
* проверяется ровно та форма, что видел живой ответ на опрос (В-211), плюс она же
|
|
* без конверта. Всё остальное обязано дать НОЛЬ записей, а не догадку.
|
|
*
|
|
* Помощники с префиксом mtsHook* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
|
* Телефоны только синтетические 7999… — реальные НИКОГДА.
|
|
*/
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
|
|
|
/** Секрет приёмника: у МТС он поедет в адресе, короче 32 знаков не принимаем. */
|
|
const MTS_HOOK_SECRET = 'mts-delivery-secret-0123456789abcdef';
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow('2026-08-15 12:00:00');
|
|
|
|
config(['services.sms.mts.webhook_secret' => MTS_HOOK_SECRET]);
|
|
config(['services.sms.mts.webhook_ips' => '']);
|
|
|
|
// Разбор письма живёт в КАНАЛЕ. В песочнице маршрутизатор держит только
|
|
// заглушку, поэтому канал МТС ставим руками — ходить в сеть он здесь не будет,
|
|
// работает только разбор.
|
|
app()->instance(SmsRouter::class, new SmsRouter([
|
|
new MtsSmsProvider('token-not-used-here', ['mts'], ['*' => 300]),
|
|
]));
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function mtsHookUrl(string $secret = MTS_HOOK_SECRET): string
|
|
{
|
|
return '/api/webhook/mts-delivery/'.$secret;
|
|
}
|
|
|
|
function mtsHookMessage(string $providerMessageId = '44747238', string $phone = '79990000001'): ClientSmsMessage
|
|
{
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$campaign = ClientSmsCampaign::create([
|
|
'tenant_id' => $tenant->id,
|
|
'title' => 'Отчёт от МТС',
|
|
'body' => 'Текст',
|
|
'sender_name' => 'LIDERRA',
|
|
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
|
'status' => ClientSmsCampaign::STATUS_DONE,
|
|
'idempotency_key' => 'mts-hook-'.uniqid(),
|
|
'segments' => 1,
|
|
'planned_count' => 1,
|
|
'sent_count' => 1,
|
|
'total_sms' => 1,
|
|
'price_rub_per_sms' => '8.50',
|
|
'estimated_cost_rub' => '8.50',
|
|
]);
|
|
|
|
return ClientSmsMessage::create([
|
|
'tenant_id' => $tenant->id,
|
|
'campaign_id' => $campaign->id,
|
|
'phone' => $phone,
|
|
'operator' => 'mts',
|
|
'provider_key' => 'mts',
|
|
'status' => ClientSmsMessage::STATUS_SENT,
|
|
'provider_message_id' => $providerMessageId,
|
|
'cost_rub' => '8.50',
|
|
'segments' => 1,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Письмо в той форме, что видел ЖИВОЙ ответ МТС на опрос (В-211).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
function mtsHookBody(string $status, string $providerMessageId = '44747238', string $phone = '79990000001'): array
|
|
{
|
|
return [
|
|
'code' => 0,
|
|
'data' => [[
|
|
'messageID' => $providerMessageId,
|
|
'statuses' => [[
|
|
'msid' => $phone,
|
|
'status' => $status,
|
|
'date' => '2026-08-15 11:58:00',
|
|
'userDeliveryDate' => '2026-08-15 11:59:00',
|
|
'partCount' => 2,
|
|
'cost' => '6.0000',
|
|
]],
|
|
]],
|
|
];
|
|
}
|
|
|
|
it('принимает отчёт МТС, правит строку журнала и отвечает кодом 204', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('Delivered'));
|
|
|
|
// 204 требует сам МТС (В-207): иной код он считает неудачей и шлёт повторы.
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED)
|
|
->and($message->delivery_raw)->toBe('Delivered')
|
|
->and($message->provider_parts)->toBe(2)
|
|
->and((string) $message->provider_cost)->toBe('6.0000')
|
|
->and($message->delivered_at)->not->toBeNull()
|
|
->and($message->delivery_checked_at)->not->toBeNull();
|
|
});
|
|
|
|
it('понимает то же письмо без конверта — списком отчётов', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('Delivered')['data']);
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
|
|
});
|
|
|
|
it('не пускает чужой адрес и журнал не трогает', function () {
|
|
config(['services.sms.mts.webhook_ips' => '203.0.113.10, 203.0.113.11']);
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
|
->postJson(mtsHookUrl(), mtsHookBody('Delivered'));
|
|
|
|
// 404, а не 403: существование адреса чужому не подтверждаем.
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('пускает адрес из списка разрешённых', function () {
|
|
config(['services.sms.mts.webhook_ips' => '203.0.113.10, 198.51.100.0/24']);
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
|
->postJson(mtsHookUrl(), mtsHookBody('Delivered'));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
|
|
});
|
|
|
|
it('не пускает с неверным секретом и журнал не трогает', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl('sovsem-drugoy-sekret-0123456789abcd'), mtsHookBody('Delivered'));
|
|
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('закрыт наглухо, пока секрет не задан', function () {
|
|
config(['services.sms.mts.webhook_secret' => '']);
|
|
$message = mtsHookMessage();
|
|
|
|
// Пустой секрет — это «приёмник не настроен», а не «пускать всех»: пустую
|
|
// строку в адресе маршрут не примет, но и любой другой ключ не подойдёт.
|
|
$response = $this->postJson(mtsHookUrl('chto-ugodno-0123456789abcdefghij'), mtsHookBody('Delivered'));
|
|
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('на отчёт про незнакомый номер сообщения отвечает 204 и не падает', function () {
|
|
$message = mtsHookMessage(providerMessageId: '44747238');
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('Delivered', providerMessageId: '99999999'));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('незнакомое слово оператора судьбу НЕ меняет', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('КакоеТоНовоеСлово'));
|
|
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
// Судьба пустая, слово сохранено как есть — угадать значило бы соврать про деньги.
|
|
expect($message->delivery_status)->toBeNull()
|
|
->and($message->delivery_raw)->toBe('КакоеТоНовоеСлово');
|
|
});
|
|
|
|
it('на письмо непонятной формы отвечает 204 и НЕ пишет ничего', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), ['events_info' => [['id' => '44747238', 'state' => 'ok']]]);
|
|
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
expect($message->delivery_status)->toBeNull()
|
|
->and($message->delivery_raw)->toBeNull()
|
|
->and($message->delivery_checked_at)->toBeNull();
|
|
});
|
|
|
|
it('денег НЕ двигает — возврат остаётся делом опроса', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('NotDelivered'));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED)
|
|
->and($message->refresh()->refunded_at)->toBeNull()
|
|
// Деньги живут в ОДНОМ доме — в команде опроса (В-146). Приёмник только
|
|
// проставляет судьбу; возврат сделает ближайший заход опроса.
|
|
// 🪤 Считаем проводки СВОЕГО клиента, а не всей базы: три десятка рекламных
|
|
// тестов идут без отката (в tests/Pest.php строка RefreshDatabase закомментирована)
|
|
// и оставляют свои проводки в базе. Общий счёт по таблице делал этот тест
|
|
// заложником соседей — в полном прогоне он видел 19 чужих записей и падал,
|
|
// хотя приёмник вёл себя правильно. Смысл проверки не изменился: приёмник
|
|
// не имеет права двигать деньги того клиента, чьё сообщение он тронул.
|
|
->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0);
|
|
});
|