Files
portal/app/tests/Feature/ClientSms/ContactsFileUploadTest.php
T
Дмитрий 1d292082ab feat(смс-клиент): потолок загрузки базы и пачечная запись вместо построчной
Строка листа 4.6 состояла из двух половин, и вторая («десятки тысяч
проходят и не падают») не выполнялась вовсе: каждый номер писался
отдельным запросом — 20 000 номеров стоили 25 278 запросов и 41 секунду.
Теперь пишем пачками по 1000 одним upsert: 23 запроса и 3.5 секунды.
Оплаченный ДаДатой оператор при повторной загрузке не стирается, дубли
внутри одной загрузки не роняют её, база не задваивается.

Потолок: колонка client_sms_settings.max_upload_phones (миграция
2026_08_01_100800, схема v9.20), по умолчанию 50 000, правится владельцем
в админке в границах 1 000…100 000. Сверх потолка загрузка отклоняется
целиком — частично загруженная база хуже незагруженной — и человек видит
оба числа. Экран говорит потолок ДО загрузки, числом с сервера.

Потолок спрашивается ПЕРЕД построчной проверкой номеров: иначе отказ на
50 001 номере занимал 20 секунд (замерено живым прогоном), а при верхней
границе запрос успел бы умереть по сроку жизни.

Ответ GET /api/sms/contacts стал объектом {items, max_upload_phones};
мёртвое поле contacts из ответа загрузки убрано.

Проверено: 14 серверных тестов, 2 фронтовых, 8 вырезов (каждый покраснел
там, где вырезан), живой прогон под боевой ролью crm_app_user и в браузере.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:30:03 +03:00

255 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\EnrichClientSmsContactsOperatorJob;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsSettings;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ClientSms\ClientSmsUploadLimit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter;
/**
* Загрузка клиентской базы номеров Excel-файлом (POST /api/sms/contacts/file).
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @param list<string> $colA */
function uploadedXlsx(array $colA): UploadedFile
{
$ss = new Spreadsheet;
$sheet = $ss->getActiveSheet();
foreach (array_values($colA) as $i => $v) {
$sheet->setCellValueExplicit('A'.($i + 1), $v, DataType::TYPE_STRING);
}
$path = tempnam(sys_get_temp_dir(), 'baza').'.xlsx';
(new XlsxWriter($ss))->save($path);
return new UploadedFile($path, 'baza.xlsx', null, null, true);
}
it('загружает базу из Excel-файла: добавляет номера, сообщает отброшенные', function () {
Bus::fake();
$file = uploadedXlsx(['Телефон', '79990000001', '+7 999 000-00-02', 'мусор']);
$res = $this->post('/api/sms/contacts/file', ['file' => $file], ['Accept' => 'application/json']);
$res->assertOk()
->assertJsonPath('added', 2) // 79990000001 + 79990000002
->assertJsonPath('rejected', 1); // «мусор»
expect($res->json('rejected_samples'))->toContain('мусор');
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(2);
Bus::assertDispatched(EnrichClientSmsContactsOperatorJob::class);
});
it('отдаёт пример файла для скачивания (Excel)', function () {
$res = $this->get('/api/sms/contacts/example');
$res->assertOk();
expect($res->headers->get('content-type'))->toContain('spreadsheetml');
expect(substr((string) $res->getContent(), 0, 2))->toBe('PK');
});
it('файл не Excel отклоняется валидацией', function () {
$bad = UploadedFile::fake()->create('spisok.txt', 1, 'text/plain');
$this->post('/api/sms/contacts/file', ['file' => $bad], ['Accept' => 'application/json'])
->assertStatus(422)
->assertJsonValidationErrors('file');
});
// ─── Потолок загрузки, строка листа 4.6 ─────────────────────────────────────
//
// Помощник с префиксом baza* — имена в Pest ГЛОБАЛЬНЫЕ, совпадение с соседним
// файлом роняет весь прогон.
/**
* Синтетические номера 7999… по счёту, ровно 11 знаков.
*
* @return list<string>
*/
function bazaPhones(int $count, int $from = 0): array
{
$phones = [];
for ($i = $from; $i < $from + $count; $i++) {
$phones[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
}
return $phones;
}
/** Ставит потолок загрузки, чтобы тесты не гоняли по 50 000 номеров зря. */
function bazaLimit(int $max): void
{
$settings = ClientSmsSettings::first() ?? new ClientSmsSettings;
$settings->fill(['max_upload_phones' => $max]);
$settings->save();
}
it('4.6 — файл сверх потолка отклоняется целиком, и сказано, сколько можно', function () {
Bus::fake();
bazaLimit(3);
$file = uploadedXlsx(array_merge(['Телефон'], bazaPhones(4)));
$res = $this->post('/api/sms/contacts/file', ['file' => $file], ['Accept' => 'application/json']);
$res->assertStatus(422)->assertJsonValidationErrors('file');
// Человек должен увидеть ОБА числа: и сколько можно, и сколько он прислал.
$message = (string) $res->json('errors.file.0');
expect($message)->toContain('3')->toContain('4');
// Частично загруженная база хуже незагруженной — не легло НИ ОДНОГО номера.
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(0);
Bus::assertNotDispatched(EnrichClientSmsContactsOperatorJob::class);
});
it('4.6 — список сверх потолка отклоняется так же, как файл', function () {
Bus::fake();
bazaLimit(3);
$res = $this->postJson('/api/sms/contacts', ['phones' => bazaPhones(4)]);
$res->assertStatus(422)->assertJsonValidationErrors('phones');
$message = (string) $res->json('errors.phones.0');
expect($message)->toContain('3')->toContain('4');
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
it('4.6 — потолок спрашивается ДО построчной проверки номеров', function () {
Bus::fake();
bazaLimit(3);
// В списке не строки, а числа: построчная проверка сказала бы про них своё
// («поле должно быть строкой»). Если человек увидит ЭТО вместо потолка —
// значит проверки идут не в том порядке, а на больших списках построчная
// стоит 20 секунд и запрос умирает, не успев объяснить, сколько можно.
$res = $this->postJson('/api/sms/contacts', ['phones' => [1, 2, 3, 4]]);
$res->assertStatus(422)->assertJsonValidationErrors('phones');
expect((string) $res->json('errors.phones.0'))->toContain('За один раз');
});
it('4.6 — ровно по потолку проходит', function () {
Bus::fake();
bazaLimit(3);
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(3)])
->assertOk()
->assertJsonPath('added', 3);
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(3);
});
it('4.6 — потолок берётся из настроек, а не зашит в код', function () {
Bus::fake();
bazaLimit(10);
// При потолке 3 (прошлый тест) эти четыре номера отбились бы. При потолке 10 — проходят.
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(4)])
->assertOk()
->assertJsonPath('added', 4);
});
it('4.6 — 20 000 номеров грузятся пачками, а не по одному запросу на номер', function () {
Bus::fake();
$queries = 0;
DB::listen(function () use (&$queries): void {
$queries++;
});
$started = microtime(true);
$res = $this->postJson('/api/sms/contacts', ['phones' => bazaPhones(20000)]);
$elapsed = microtime(true) - $started;
fwrite(STDERR, sprintf("\n[4.6] загрузка 20 000 номеров: %d запросов, %.2f с\n", $queries, $elapsed));
$res->assertOk()->assertJsonPath('added', 20000);
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(20000);
// 🔑 Прибор — ЗАПРОСЫ, а не время: время на стенде плавает и порогом врало бы.
// Построчная запись (updateOrCreate на номер) даёт 20 000+ запросов, пачками — десятки.
expect($queries)->toBeLessThan(200);
});
it('4.6 — повторная загрузка тех же номеров не задваивает базу', function () {
Bus::fake();
$phones = bazaPhones(50);
$this->postJson('/api/sms/contacts', ['phones' => $phones])->assertOk();
$this->postJson('/api/sms/contacts', ['phones' => $phones])->assertOk();
// Запрет дублей стоит на паре «клиент + номер» (миграция 2026_07_27_100400).
// Пачечная запись обязана его уважать так же, как построчная.
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(50);
});
it('4.6 — одинаковые номера ВНУТРИ одной загрузки не роняют её', function () {
Bus::fake();
// Человек прислал один и тот же номер дважды в одном файле — обычное дело.
$res = $this->postJson('/api/sms/contacts', [
'phones' => ['79990000001', '+7 999 000-00-01', '79990000002'],
]);
$res->assertOk()->assertJsonPath('added', 2);
expect(ClientSmsContact::where('tenant_id', $this->tenant->id)->count())->toBe(2);
});
it('4.6 — повторная загрузка не стирает оператора, за которого мы заплатили ДаДате', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk();
// ДаДата ответила — оператор и пояс легли в базу (мы за этот ответ заплатили).
ClientSmsContact::where('tenant_id', $this->tenant->id)
->where('phone', '79990000001')
->update(['operator' => 'МТС', 'tz_offset_minutes' => 180]);
// Клиент загрузил тот же файл ещё раз.
$this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk();
$contact = ClientSmsContact::where('tenant_id', $this->tenant->id)->first();
expect($contact->operator)->toBe('МТС')
->and($contact->tz_offset_minutes)->toBe(180);
});
it('4.6 — экран узнаёт потолок с сервера, а не выдумывает его', function () {
bazaLimit(7000);
$this->getJson('/api/sms/contacts')
->assertOk()
->assertJsonPath('max_upload_phones', 7000);
});
it('4.6 — без строки настроек потолок всё равно есть', function () {
// Строка настроек на стенде обычно есть (её заводит миграция), но код не имеет
// права на неё РАССЧИТЫВАТЬ: нет строки — потолок обязан остаться, иначе
// загрузка либо не ограничена вовсе, либо запрещена целиком.
ClientSmsSettings::query()->delete();
expect(app(ClientSmsUploadLimit::class)->max())->toBe(ClientSmsUploadLimit::DEFAULT);
});