Files
portal/app/tests/Feature/Obzvon/ObzvonTariffTest.php
T
Дмитрий 1c2195ebee fix(обзвон): два нуля в тарифе разом запрещены, один ноль можно
Решение владельца Р90 (docs/grilling/2026-08-03-metodika-lena-pod-klienta.md):
цена звонка — два поля, за соединение и за минуту. Оба нуля разом означали
молча бесплатный обзвон: клиент не платит, а оператору за звонки платим мы.
Одна цифра в нуле — осмысленный случай, например не брать отдельно за
соединение.

AdminObzvonTariffController::update() теперь сравнивает обе цены ПОСЛЕ
перевода в копейки и отказывает с объяснением, если обе — ноль. Ловит «0»,
«0.00» и «0.0» одинаково, а не по виду строки. Прежние отказы (пустая,
отрицательная, нечисловая цена) не тронуты.

ObzvonTariffTest.php: +6 проверок «З-1.8». Сторож доказан красным дважды —
до фикса (естественный RED) и после снятия запрета (искусственный RED),
восстановление сверено побайтово через git hash-object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:29:09 +03:00

477 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ObzvonTariff;
use App\Models\SaasAdminAuditLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Obzvon\ObzvonTarifikator;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Сторожа задачи З-1.3 «Тариф обзвона: цена, где живёт, кто правит».
*
* Беда: правило цены звонка написано и работает (`ObzvonTarifikator`), но самих
* двух цифр — за снятую трубку и за начатую минуту — хранить негде и менять
* некому. Пока их нет в базе, поправить цену можно только руками программиста и
* новой сборкой. А цена назначена вслепую: сколько нам самим стоит звонок, ни
* разу не измерено, значит менять её будут точно.
*
* Каждый тест ниже краснеет, если беда возвращается: цифры пропали из базы,
* админка перестала их принимать, приняла мусор, смена тарифа переписала
* вчерашние счета, в тариф пустили не админа или правка перестала оставлять след.
*/
uses(DatabaseTransactions::class);
/** Адрес ручки — один на весь файл, чтобы опечатка не пряталась по копиям. */
const ADRES_TARIFA = '/api/admin/obzvon/tariff';
/**
* Счётчик, заряженный ДЕЙСТВУЮЩИМ тарифом. Собирается двумя строками у зовущего —
* так велит сам счётчик в своей шапке, и так же требует правило слоёв: модели
* тарифа звать службу нельзя.
*/
function schetchikPoDeystvuyushchemu(): ObzvonTarifikator
{
$tarif = ObzvonTariff::deystvuyushchiy();
return new ObzvonTarifikator(
zaSnyatuyuTrubkuKop: $tarif->answer_kopecks,
zaNachatuyuMinutuKop: $tarif->minute_kopecks,
);
}
/** Строка звонка с обязательным минимумом — как в сторожах З-1.1. */
function zvonokTarifa(int $tenantId, array $polya = []): int
{
return (int) DB::table('obzvon_calls')->insertGetId(array_merge([
'tenant_id' => $tenantId,
'phone' => '79001234567',
'direction' => 'outbound',
'started_at' => now(),
'created_at' => now(),
'updated_at' => now(),
], $polya));
}
// -----------------------------------------------------------------------------
// Место под две цифры вообще есть
// -----------------------------------------------------------------------------
it('две цифры тарифа лежат в базе целыми копейками, а не дробными числами', function () {
expect(Schema::hasTable('obzvon_tariffs'))->toBeTrue();
expect(Schema::hasColumns('obzvon_tariffs', ['answer_kopecks', 'minute_kopecks']))->toBeTrue();
foreach (['answer_kopecks', 'minute_kopecks'] as $stolbec) {
$tip = DB::selectOne(
"select data_type from information_schema.columns
where table_name = 'obzvon_tariffs' and column_name = ?",
[$stolbec]
);
expect($tip->data_type)->toBe('integer', "цена {$stolbec} обязана быть целыми копейками");
}
});
it('умолчание владельца — 2 ₽ за трубку и 10 ₽ за минуту', function () {
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(200)
->and($tarif->minute_kopecks)->toBe(1000);
// Для человека те же два числа — рублями. Перевод обратно делает счётчик,
// модели тарифа звать его нельзя правилом слоёв.
expect(ObzvonTarifikator::rubli($tarif->answer_kopecks))->toBe('2.00')
->and(ObzvonTarifikator::rubli($tarif->minute_kopecks))->toBe('10.00');
});
it('GET отдаёт админу обе цифры и в копейках, и в рублях', function () {
$r = $this->getJson(ADRES_TARIFA);
$r->assertStatus(200);
expect($r->json('tariff.answer_kopecks'))->toBe(200)
->and($r->json('tariff.minute_kopecks'))->toBe(1000)
->and($r->json('tariff.answer_rub'))->toBe('2.00')
->and($r->json('tariff.minute_rub'))->toBe('10.00');
});
// -----------------------------------------------------------------------------
// Проверка 1 задачи: поменяли — следующий звонок посчитан по новому
// -----------------------------------------------------------------------------
it('проверка 1 — поменяли на 3 ₽ и 12 ₽, и следующий звонок посчитан по новому', function () {
$this->putJson(ADRES_TARIFA, ['answer_rub' => '3.00', 'minute_rub' => '12.00'])
->assertStatus(200);
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(300)
->and($tarif->minute_kopecks)->toBe(1200);
// Десять секунд разговора — одна начатая минута: 3 ₽ + 12 ₽ = 15 ₽.
$schet = schetchikPoDeystvuyushchemu()->schet(trubkaSnyata: true, sekundRobota: 10);
expect($schet['kopeek'])->toBe(1500)
->and($schet['minut'])->toBe(1);
});
it('единицы сошлись со счётчиком: рубли из админки и его izRubley дают ОДИН ответ', function () {
// 🔴 Перевод рубли → копейки существует в двух местах: у счётчика
// (ObzvonTarifikator::izRubley) и на входе админки. Разъехавшись, они однажды
// потеряют копейку. Этот сторож держит их вместе: он краснеет, как только
// множитель или округление в одном из мест изменится.
$this->putJson(ADRES_TARIFA, ['answer_rub' => '3.49', 'minute_rub' => '12.01'])
->assertStatus(200);
$izBazy = schetchikPoDeystvuyushchemu()->schet(trubkaSnyata: true, sekundRobota: 61);
$izRubley = ObzvonTarifikator::izRubley('3.49', '12.01')
->schet(trubkaSnyata: true, sekundRobota: 61);
expect($izBazy)->toBe($izRubley)
->and($izBazy['kopeek'])->toBe(349 + 2 * 1201);
});
// -----------------------------------------------------------------------------
// Проверка 2 задачи: вчерашние звонки не пересчитались
// -----------------------------------------------------------------------------
it('проверка 2 — вчерашние звонки не пересчитались: цена лежит в строке звонка', function () {
$tenant = Tenant::factory()->create();
// Вчерашний звонок, посчитанный по прежнему тарифу 2 ₽ + 10 ₽.
$id = zvonokTarifa($tenant->id, [
'answered' => true,
'leg1_duration_sec' => 70,
'billed_minutes' => 2,
'tariff_answer_kopecks' => 200,
'tariff_minute_kopecks' => 1000,
'price_kopecks' => 2200,
'started_at' => now()->subDay(),
]);
$this->putJson(ADRES_TARIFA, ['answer_rub' => '3.00', 'minute_rub' => '12.00'])
->assertStatus(200);
$stroka = DB::table('obzvon_calls')->where('id', $id)->first();
// Ни цена, ни снимок тарифа не сдвинулись — счёт за август остаётся августовским.
expect((int) $stroka->price_kopecks)->toBe(2200)
->and((int) $stroka->tariff_answer_kopecks)->toBe(200)
->and((int) $stroka->tariff_minute_kopecks)->toBe(1000);
// А новый тариф при этом ДЕЙСТВИТЕЛЬНО другой — иначе проверка выше
// «сходилась» бы просто потому, что менять было нечего.
expect(ObzvonTariff::deystvuyushchiy()->answer_kopecks)->toBe(300);
});
// -----------------------------------------------------------------------------
// Проверка 3 задачи: мусор в цене не принимается
// -----------------------------------------------------------------------------
it('проверка 3 — отрицательная, пустая и нечисловая цена не принимаются', function () {
$musor = [
'отрицательная трубка' => ['answer_rub' => '-1.00', 'minute_rub' => '10.00'],
'отрицательная минута' => ['answer_rub' => '2.00', 'minute_rub' => '-0.01'],
'пустая трубка' => ['answer_rub' => '', 'minute_rub' => '10.00'],
'пустой минуты нет вовсе' => ['answer_rub' => '2.00'],
'не число' => ['answer_rub' => 'дёшево', 'minute_rub' => '10.00'],
'три знака после точки' => ['answer_rub' => '2.005', 'minute_rub' => '10.00'],
'запятая вместо точки' => ['answer_rub' => '2,50', 'minute_rub' => '10.00'],
];
foreach ($musor as $chto => $telo) {
// Ответ берём числом, а не assertStatus: так в красном видно, КАКОЙ
// именно мусор проехал, а не просто «422 ожидали, 200 получили».
expect($this->putJson(ADRES_TARIFA, $telo)->getStatusCode())
->toBe(422, "админка приняла мусор: {$chto}");
}
// Тариф не тронут ни одной из попыток.
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(200)
->and($tarif->minute_kopecks)->toBe(1000);
});
it('база сама не принимает отрицательную цену — не только админка', function () {
// 🪤 Первая половина обязательна: без неё тест был бы зелёным и тогда, когда
// таблицы нет вовсе — «упало» означало бы «нет такой таблицы», а не «замок держит».
expect(DB::table('obzvon_tariffs')->where('id', 1)->update(['answer_kopecks' => 250]))->toBe(1);
expect(fn () => DB::table('obzvon_tariffs')->where('id', 1)->update(['answer_kopecks' => -1]))
->toThrow(QueryException::class);
});
it('строка тарифа одна: вторую база не примет', function () {
// Две строки тарифа означали бы два разных ответа на вопрос «сколько стоит
// звонок», и какой из них попадёт в счёт — как повезёт.
// 🪤 Та же двусторонность: сперва доказываем, что строка ровно одна и есть.
expect(DB::table('obzvon_tariffs')->count())->toBe(1);
expect(fn () => DB::table('obzvon_tariffs')->insert([
'answer_kopecks' => 500,
'minute_kopecks' => 500,
'created_at' => now(),
'updated_at' => now(),
]))->toThrow(QueryException::class);
});
// -----------------------------------------------------------------------------
// З-1.8: ноль за соединение можно, два нуля разом — нельзя (решение владельца
// Р90, docs/grilling/2026-08-03-metodika-lena-pod-klienta.md). Оба поля в
// нуле разом означают молча бесплатный обзвон: клиент не платит, а оператору
// за звонок платим мы. Одна цифра в нуле — осмысленный случай.
// -----------------------------------------------------------------------------
it('З-1.8 проверка 1 — оба нуля разом не проходят, и человеку сказано ПОЧЕМУ', function () {
$otvet = $this->putJson(ADRES_TARIFA, ['answer_rub' => '0', 'minute_rub' => '0']);
$otvet->assertStatus(422);
$soobshcheniya = mb_strtolower(collect($otvet->json('errors'))->flatten()->implode(' '));
// Не просто «неверное значение» — обязано объяснять, ПОЧЕМУ два нуля нельзя.
expect($soobshcheniya)->toContain('бесплат')
->and($soobshcheniya)->not->toContain('неверное значение');
// Тариф не тронут отказом.
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(200)
->and($tarif->minute_kopecks)->toBe(1000);
});
it('З-1.8 проверка 1 — «0», «0.00» и «0.0» ловятся одинаково: ноль есть ноль после перевода в копейки', function () {
// Цена приходит строкой в рублях, а в базе лежит копейками. Запрет обязан
// смотреть на копейки, а не на вид строки — иначе один и тот же ноль,
// записанный по-разному, то ловился бы, то проходил.
foreach ([['0', '0'], ['0.00', '0.00'], ['0.0', '0.0'], ['0', '0.00'], ['0.00', '0']] as [$otvet, $minuta]) {
expect(
$this->putJson(ADRES_TARIFA, ['answer_rub' => $otvet, 'minute_rub' => $minuta])->getStatusCode()
)->toBe(422, "форма «{$otvet}» + «{$minuta}» обязана быть отказом");
}
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(200)
->and($tarif->minute_kopecks)->toBe(1000);
});
it('З-1.8 проверка 2 — ноль за соединение при ненулевой минуте принимается', function () {
$this->putJson(ADRES_TARIFA, ['answer_rub' => '0', 'minute_rub' => '12.00'])
->assertStatus(200);
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(0)
->and($tarif->minute_kopecks)->toBe(1200);
});
it('З-1.8 проверка 3 — ноль за минуту при ненулевом соединении принимается', function () {
$this->putJson(ADRES_TARIFA, ['answer_rub' => '5.00', 'minute_rub' => '0'])
->assertStatus(200);
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(500)
->and($tarif->minute_kopecks)->toBe(0);
});
it('З-1.8 проверка 4 — обе цифры ненулевые принимаются, как и раньше', function () {
$this->putJson(ADRES_TARIFA, ['answer_rub' => '3.00', 'minute_rub' => '12.00'])
->assertStatus(200);
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(300)
->and($tarif->minute_kopecks)->toBe(1200);
});
it('З-1.8 проверка 5 — прежние отказы не сломались: отрицательная и пустая цена по-прежнему не идут', function () {
$musor = [
'отрицательная трубка' => ['answer_rub' => '-1.00', 'minute_rub' => '10.00'],
'отрицательная минута' => ['answer_rub' => '2.00', 'minute_rub' => '-0.01'],
'пустая трубка' => ['answer_rub' => '', 'minute_rub' => '10.00'],
'пустой минуты нет вовсе' => ['answer_rub' => '2.00'],
];
foreach ($musor as $chto => $telo) {
expect($this->putJson(ADRES_TARIFA, $telo)->getStatusCode())
->toBe(422, "старый отказ сломался: {$chto}");
}
$tarif = ObzvonTariff::deystvuyushchiy();
expect($tarif->answer_kopecks)->toBe(200)
->and($tarif->minute_kopecks)->toBe(1000);
});
// -----------------------------------------------------------------------------
// Проверка 4 задачи: до тарифа дотягивается только админ
// -----------------------------------------------------------------------------
it('проверка 4 — до тарифа не дотягивается никто, кроме прошедшего дверь админки', function () {
// В бою дверь админ-зоны держат nginx-пароль и гейт по REMOTE_USER; в тестах
// гейт выключен по умолчанию, поэтому включаем его руками — иначе проверка
// «права» ничего бы не проверяла.
config(['admin.basic_auth_gate' => true, 'admin.basic_auth_allowlist' => ['admin']]);
// Обе половины обязательны: без пропускающей «403» доказывало бы лишь то,
// что адреса не существует.
$this->getJson(ADRES_TARIFA)->assertStatus(403);
$this->putJson(ADRES_TARIFA, ['answer_rub' => '9.00', 'minute_rub' => '9.00'])->assertStatus(403);
$this->withServerVariables(['REMOTE_USER' => 'admin'])->getJson(ADRES_TARIFA)->assertStatus(200);
$this->withServerVariables(['REMOTE_USER' => 'eve'])->getJson(ADRES_TARIFA)->assertStatus(403);
// Цена не сдвинулась ни от одной непрошедшей попытки.
expect(ObzvonTariff::deystvuyushchiy()->answer_kopecks)->toBe(200);
});
it('проверка 4 — обычный пользователь портала до тарифа не дотягивается', function () {
config(['admin.basic_auth_gate' => true, 'admin.basic_auth_allowlist' => ['admin']]);
$tenant = Tenant::factory()->create();
$this->actingAs(User::factory()->for($tenant)->create());
// Вход в портал не открывает админ-зону: это разные двери.
$this->getJson(ADRES_TARIFA)->assertStatus(403);
$this->putJson(ADRES_TARIFA, ['answer_rub' => '1.00', 'minute_rub' => '1.00'])->assertStatus(403);
expect(ObzvonTariff::deystvuyushchiy()->minute_kopecks)->toBe(1000);
});
it('проверка 4 — во время входа под клиентом в тариф не пускают', function () {
// Запрет эскалации: админ, сидящий в кабинете клиента, не правит общий прайс.
$this->withHeaders(['Authorization' => 'Bearer lpimp_test_token'])
->putJson(ADRES_TARIFA, ['answer_rub' => '9.00', 'minute_rub' => '9.00'])
->assertStatus(403);
expect(ObzvonTariff::deystvuyushchiy()->answer_kopecks)->toBe(200);
});
// -----------------------------------------------------------------------------
// Проверка 5 задачи: смена тарифа оставляет след
// -----------------------------------------------------------------------------
it('проверка 5 — смена тарифа оставляет след: кто, когда, с чего на что', function () {
$adminId = DB::table('saas_admin_users')->insertGetId([
'email' => 'admin-obzvon-tarif@liderra.ru',
'full_name' => 'Obzvon Tariff Admin',
'password_hash' => '$2y$04$dummy-hash-for-test',
'role' => 'super_admin',
'is_active' => true,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
$this->putJson(
ADRES_TARIFA,
[
'answer_rub' => '3.00',
'minute_rub' => '12.00',
'reason' => 'Замерили себестоимость звонка, поднимаем цену.',
'admin_user_id' => $adminId,
],
['User-Agent' => 'TestRunner/1.0'],
)->assertStatus(200);
$sled = SaasAdminAuditLog::query()
->where('action', 'obzvon_tariff.update')
->latest('id')
->first();
expect($sled)->not->toBeNull();
// КТО
expect($sled->admin_user_id)->toBe($adminId);
// КОГДА
expect($sled->created_at)->not->toBeNull();
// С ЧЕГО
expect($sled->payload_before)->toBe(['answer_kopecks' => 200, 'minute_kopecks' => 1000]);
// НА ЧТО
expect($sled->payload_after)->toBe(['answer_kopecks' => 300, 'minute_kopecks' => 1200]);
// Откуда пришли
expect($sled->ip_address)->not->toBeEmpty()
->and($sled->user_agent)->toBe('TestRunner/1.0')
->and($sled->reason)->toContain('себестоимость');
});
it('след пишется даже тогда, когда основание не назвали', function () {
$bylo = SaasAdminAuditLog::query()->where('action', 'obzvon_tariff.update')->count();
$this->putJson(ADRES_TARIFA, ['answer_rub' => '4.00', 'minute_rub' => '11.00'])
->assertStatus(200);
expect(SaasAdminAuditLog::query()->where('action', 'obzvon_tariff.update')->count())
->toBe($bylo + 1);
});
it('отказ следа не оставляет: мусор в цене в журнал не попадает', function () {
$bylo = SaasAdminAuditLog::query()->where('action', 'obzvon_tariff.update')->count();
$this->putJson(ADRES_TARIFA, ['answer_rub' => '-5.00', 'minute_rub' => '12.00'])
->assertStatus(422);
expect(SaasAdminAuditLog::query()->where('action', 'obzvon_tariff.update')->count())
->toBe($bylo);
});
// -----------------------------------------------------------------------------
// Канон схемы и живая база описывают одно и то же
// -----------------------------------------------------------------------------
it('канон схемы и живая база описывают таблицу тарифа одинаково', function () {
// 🪤 Сверять со снятием \r — иначе разойдётся на переводах строк, а не по делу.
$put = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema_modules.sql';
expect(is_readable($put))->toBeTrue();
$kanon = str_replace("\r", '', (string) file_get_contents($put));
$est = preg_match('/^CREATE TABLE obzvon_tariffs \((.*?)^\);/ms', $kanon, $m);
expect($est)->toBe(1, 'в каноне нет описания таблицы obzvon_tariffs');
$izKanona = [];
foreach (explode("\n", $m[1]) as $stroka) {
$s = trim($stroka);
if ($s === '' || str_starts_with($s, '--')) {
continue;
}
if (preg_match('/^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT|EXCLUDE)\b/i', $s)) {
continue;
}
if (preg_match('/^([a-z][a-z0-9_]*)\s+[A-Z]/', $s, $c)) {
$izKanona[] = $c[1];
}
}
$izBazy = array_map(
static fn ($r) => $r->column_name,
DB::select(
"select column_name from information_schema.columns
where table_schema = 'public' and table_name = 'obzvon_tariffs'
order by ordinal_position"
)
);
sort($izKanona);
sort($izBazy);
expect($izKanona)->toBe($izBazy, 'канон и база разошлись по столбцам obzvon_tariffs');
});
it('цена назначена вслепую — и это сказано вслух рядом с самой ценой', function () {
// 🔴 Требование задачи: через месяц никто не должен считать 2 ₽ и 10 ₽
// обоснованными. Сторож краснеет, если оговорку вычистят «как лишний текст».
$migraciya = glob(database_path('migrations/*create_obzvon_tariffs.php'));
expect($migraciya)->not->toBeEmpty();
$kanon = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema_modules.sql';
// Регистр не важен — важно, что оговорка написана там же, где числа.
foreach ([app_path('Models/ObzvonTariff.php'), $migraciya[0], $kanon] as $fayl) {
// 🪤 toContain у Pest переменное число ИСКОМЫХ строк, а не «искомое +
// пояснение»: второй довод искался бы как второй кусок текста.
expect(mb_strtolower((string) file_get_contents($fayl)))->toContain('вслепую');
}
// И человек видит её на экране, а не только программист в коде.
expect($this->getJson(ADRES_TARIFA)->json('note'))->toContain('вслепую');
});