00cc072e2f
Это снимает запрет на выкат телеграм-рекламы. Пункт «Моя база номеров» стоял в выборе аудитории с 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>
146 lines
7.3 KiB
PHP
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);
|
|
});
|