1c2195ebee
Решение владельца Р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>
477 lines
25 KiB
PHP
477 lines
25 KiB
PHP
<?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('вслепую');
|
||
});
|