Files
portal/app/tests/Feature/ClientTg/BazaNomerovTest.php
T
Дмитрий 00cc072e2f feat,телеграм-реклама: «Моя база номеров» заработала — пункт больше не ведёт в никуда
Это снимает запрет на выкат телеграм-рекламы.

Пункт «Моя база номеров» стоял в выборе аудитории с 27.07, а писать в таблицу
client_tg_contacts не умела НИ ОДНА строка кода: ни экрана загрузки, ни серверной
ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
Клиент выбрал бы «свою базу», увидел пустоту и решил, что портал потерял его
клиентов. Песочница выключена, деньги живые — катить в таком виде было нельзя.

Читающая половина при этом была готова с самого начала: TelegramAudienceService
умеет и нормализацию, и схлопывание дублей, и вычитание стоп-листа. Не хватало
только записи, поэтому работа вышла куда меньше, чем казалось.

Сервер:
- TelegramBazaService — разбор файла, замена базы, очистка. Номер берётся по
  одному в строке либо первым столбцом таблицы, разделители запятая, точка с
  запятой, табуляция. Мусорные строки не роняют разбор, а считаются отдельно.
- три ручки: GET, POST и DELETE /api/telegram/contacts.
- потолок 200 000 номеров и 10 МБ на файл; обрыв по потолку не молчит, а
  возвращается признаком.

Экран, в шаге «Кому показываем»:
- сколько номеров в базе, заливка файла, очистка;
- итог заливки целиком: принято, повторов, не похоже на номер. «Принято 1200»
  без остального читалось бы как «файл зашёл полностью»;
- после заливки счётчик охвата пересчитывается сам.

Решения, которые стоит знать:
- заливка ЗАМЕНЯЕТ базу, а не добавляет. Так предсказуемее: клиент держит базу у
  себя и заливает заново. Подмешивание копило бы номера, от которых он не смог бы
  избавиться — построчного удаления в интерфейсе нет. На экране это написано до
  нажатия, молчаливой потери нет.
- файл без единого годного номера отклоняется, старая база остаётся цела. Иначе
  клиент залил бы файл не того формата и потерял всё.
- замена сделана удалением и вставкой, а не upsert: право UPDATE на таблице не
  выдано, upsert упёрся бы в это на бою и молча правил бы ноль строк.
- наружу отдаём только счётчики. Номера — персональные данные, экрану они не
  нужны и в ответах не появляются.

Приёмка: 12 тестов сервера и 9 тестов экрана, все до кода и все красные по верной
причине — сервер отвечал 405, блока на экране не было.

Замеры: телеграм-модуль 325 тестов 0 падений, экраны 244 файла 1855 тестов
0 падений, статанализ 0, форматтер чисто. Проверка типов 6 ошибок, все чужие,
столько же было до работы.

Для выката, проверить на бою: право USAGE на счётчике client_tg_contacts_id_seq.
Замерил в тестовой базе — счётчик без права, но ровно так же выглядит и счётчик
client_tg_campaigns_id_seq, в который портал на бою пишет. То есть новых прав эта
работа не требует, но проверка дешёвая, а пропущенный grant на бою даёт отказ,
которого на dev не видно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:13:31 +03:00

146 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
/**
* «Моя база номеров» — загрузка, замена и очистка.
*
* 🔴 Зачем появилось. Пункт «Моя база номеров» стоял на экране с 27.07.2026, а писать
* в таблицу `client_tg_contacts` НЕ УМЕЛА ни одна строка кода: ни экрана загрузки, ни
* серверной ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
* Читающая половина при этом была готова с самого начала —
* `TelegramAudienceService::fromBase` умеет и нормализацию, и дубли, и стоп-лист.
*
* 🪤 Ловушка, из-за которой это не поймали 292 зелёных теста: прежние тесты «своей базы»
* САМИ вписывали контакты через Contact::create и потому доходили до состояния, до
* которого клиент дойти не мог. Проверяя источник данных, спрашивать надо не «читается
* ли он», а «кто в него пишет».
*
* Номера 7999… синтетические — настоящих ПДн в тестах нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withHeaders(['Accept' => 'application/json']);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Файл базы — как его выгружает обычная таблица: по номеру на строку. */
function faylBazy(string $soderzhimoe, string $imya = 'baza.csv'): UploadedFile
{
return UploadedFile::fake()->createWithContent($imya, $soderzhimoe);
}
it('пустая база честно говорит про ноль, а не молчит', function () {
$this->getJson('/api/telegram/contacts')
->assertOk()
->assertJson(['count' => 0]);
});
it('загруженные номера ложатся в базу и она перестаёт быть пустой', function () {
$this->post('/api/telegram/contacts', [
'file' => faylBazy("79990000001\n79990000002\n79990000003\n"),
])->assertOk()->assertJson(['count' => 3, 'accepted' => 3]);
expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(3);
});
it('номера приводятся к единому виду — как их ни записали в файле', function () {
$this->post('/api/telegram/contacts', [
'file' => faylBazy("+7 999 000-00-01\n8(999)0000002\n9990000003\n"),
])->assertOk()->assertJson(['accepted' => 3]);
// Все три легли одинаково — иначе один и тот же человек попал бы в базу трижды.
expect(Contact::where('tenant_id', $this->tenant->id)->pluck('phone')->sort()->values()->all())
->toBe(['79990000001', '79990000002', '79990000003']);
});
it('повторы внутри файла схлопываются и считаются отдельно', function () {
$this->post('/api/telegram/contacts', [
'file' => faylBazy("79990000001\n8 999 000 00 01\n79990000002\n"),
])->assertOk()->assertJson(['count' => 2, 'accepted' => 2, 'duplicates' => 1]);
});
it('мусорные строки не роняют загрузку, а попадают в счётчик негодных', function () {
$this->post('/api/telegram/contacts', [
'file' => faylBazy("телефон\n79990000001\nне номер\n123\n79990000002\n"),
])->assertOk()->assertJson(['count' => 2, 'accepted' => 2, 'rejected' => 3]);
});
it('вторая загрузка ЗАМЕНЯЕТ базу, а не подмешивает к старой', function () {
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n79990000002\n")])->assertOk();
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000009\n")])->assertOk()
->assertJson(['count' => 1]);
expect(Contact::where('tenant_id', $this->tenant->id)->pluck('phone')->all())->toBe(['79990000009']);
});
it('базу можно очистить целиком', function () {
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk();
$this->deleteJson('/api/telegram/contacts')->assertOk()->assertJson(['count' => 0]);
expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
it('файл совсем без годных номеров отклоняется, а старая база остаётся цела', function () {
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk();
$this->post('/api/telegram/contacts', ['file' => faylBazy("шапка\nне номер\n")])
->assertStatus(422);
// Иначе клиент залил бы кривой файл и молча потерял всю базу.
expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
it('чужую базу не видно и не тронуть', function () {
$chuzhoy = Tenant::factory()->create();
Contact::create(['tenant_id' => $chuzhoy->id, 'phone' => '79995550001']);
$this->getJson('/api/telegram/contacts')->assertOk()->assertJson(['count' => 0]);
$this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk();
// Замена базы своего тенанта не должна была снести чужие строки.
expect(Contact::where('tenant_id', $chuzhoy->id)->count())->toBe(1);
});
it('после загрузки охват по своей базе наконец считается — ради этого всё и делалось', function () {
$this->post('/api/telegram/contacts', [
'file' => faylBazy("79990000001\n79990000002\n79990000003\n"),
])->assertOk();
$this->postJson('/api/telegram/campaigns/estimate', ['audience_kind' => 'base'])
->assertOk()
->assertJson(['planned_count' => 3]);
});
it('стоп-лист вычитается из базы — помеченный номер рекламу не увидит', function () {
Optout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002']);
$this->post('/api/telegram/contacts', [
'file' => faylBazy("79990000001\n79990000002\n79990000003\n"),
])->assertOk()->assertJson(['count' => 3]);
// В базе номер лежит, но в аудиторию не попадает — стоп-лист сильнее.
$this->postJson('/api/telegram/campaigns/estimate', ['audience_kind' => 'base'])
->assertOk()
->assertJson(['planned_count' => 2]);
});
it('без входа в портал база недоступна', function () {
app('auth')->forgetGuards();
$this->postJson('/api/telegram/contacts', [])->assertStatus(401);
$this->getJson('/api/telegram/contacts')->assertStatus(401);
});