Files
portal/app/tests/Feature/ClientSms/ContactsFileUploadTest.php
T
Дмитрий 284163de5a feat(смс-клиент): своя база листается страницами, а не грузится целиком
Строка листа 4.7. База клиента бывает на десятки тысяч номеров, и экран
получал её одним куском: замер на 20 000 номерах — 5.09 с и 3.60 МБ в одном
ответе. Стало 0.04 с и 0.01 МБ на страницу.

Что сделано:
· GET /api/sms/contacts отдаёт страницу по 50 номеров и поля страницы
  (total / page / per_page / last_page) в том же объекте, где уже жил потолок
  загрузки — форму ответа меняли ОДИН раз, в прошлой задаче (В-160);
· больше 200 номеров за один ответ не отдаётся НИКОМУ (В-166): иначе
  постраничность обходится параметром ?per_page=100000 и обязательство
  «не грузится целиком» остаётся невыполненным;
· экран показывает «Всего номеров в базе: N» и листалку, страницу берёт у
  сервера, а не режет список у себя.

Живой прогон под боевой ролью нашёл то, чего не видел ни один из шести
зелёных тестов (В-167): вторая страница отдавала ТЕ ЖЕ номера, что первая —
paginate() брал номер страницы из глобального запроса приложения, а не из
переданного в метод. По HTTP это одно и то же, поэтому тесты через getJson
врали хором. Номер страницы читается явно; прибор — тест, зовущий метод так
же, как живой прогон.

Двух мест план не касался вовсе, оба про враньё экрана:
· удаление номера убирало строку только на экране — при страницах осталось бы
  49 из 50, «всего» не менялось бы, а номер со следующей страницы не
  подтягивался (В-164); теперь страница перечитывается, а опустевшая
  последняя отступает на шаг назад;
· после загрузки человек оставался на прежней странице и не видел ни одного
  своего нового номера при надписи «Добавлено: N» (В-165) — теперь возвращаем
  на первую страницу, где эти номера и лежат.

Проверено: ClientSms 305/305, приём лидов 17/17, фронт 1694 + 3 пропущенных,
phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Восемь вырезов,
каждый покраснел там, где вырезан. В браузере: 50 строк, «Всего номеров в
базе: 20 000», листалка 1…400, переход на вторую страницу уходит запросом
?page=2 (ответ 9.6 КБ). Стенд возвращён и сверен со снимком до работы.

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

364 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use App\Http\Controllers\Api\Client\ClientSmsBaseController;
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\Request;
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);
});
// ─── Постраничный список базы, строка листа 4.7 ─────────────────────────────
//
// Своя база может быть на десятки тысяч номеров. Отдавать её экрану одним
// куском — это мегабайты в одном ответе и подвисший экран у человека.
it('4.7 — список отдаёт СТРАНИЦУ, а не всю базу, и говорит, сколько всего', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(150)])->assertOk();
$res = $this->getJson('/api/sms/contacts');
$res->assertOk()
->assertJsonPath('total', 150) // сколько ВСЕГО в базе — человеку это и показываем
->assertJsonPath('page', 1)
->assertJsonPath('per_page', 50)
->assertJsonPath('last_page', 3);
expect($res->json('items'))->toHaveCount(50);
// Потолок загрузки из ответа не пропал: он приехал сюда в Task 5 и экран его
// читает ДО загрузки (строка 4.6). Добавляя страницы, ломать это нельзя.
expect($res->json('max_upload_phones'))->toBe(ClientSmsUploadLimit::DEFAULT);
});
it('4.7 — вторая страница отдаёт СЛЕДУЮЩИЕ номера, а не те же самые', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(150)])->assertOk();
$first = collect($this->getJson('/api/sms/contacts?page=1')->json('items'))->pluck('id');
$second = collect($this->getJson('/api/sms/contacts?page=2')->json('items'))->pluck('id');
expect($second)->toHaveCount(50)
->and($second->intersect($first))->toBeEmpty(); // ни одного повтора
});
it('4.7 — последняя страница отдаёт остаток, а не пустоту', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(120)])->assertOk();
$res = $this->getJson('/api/sms/contacts?page=3');
$res->assertOk()->assertJsonPath('last_page', 3);
expect($res->json('items'))->toHaveCount(20);
});
it('4.7 — всю базу нельзя вытянуть, попросив огромную страницу', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(300)])->assertOk();
// 🔑 Обязательство «не грузится целиком» держится НЕ тем, что экран просит по 50,
// а тем, что больше отдать нельзя: иначе постраничность обходится одним
// параметром в адресе (В-166). Тот же приём, что у потолка загрузки в 4.6.
$res = $this->getJson('/api/sms/contacts?per_page=100000');
$res->assertOk()->assertJsonPath('per_page', 200);
expect($res->json('items'))->toHaveCount(200);
});
it('4.7 — размер страницы в разумных пределах слушается', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(30)])->assertOk();
$res = $this->getJson('/api/sms/contacts?per_page=10');
$res->assertOk()->assertJsonPath('per_page', 10)->assertJsonPath('last_page', 3);
expect($res->json('items'))->toHaveCount(10);
});
it('4.7 — номер страницы берётся из СВОЕГО запроса, а не из глобального', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(150)])->assertOk();
// 🔑 Зовём метод так же, как его зовёт живой прогон: напрямую, со своим запросом.
// Через getJson этой дыры не видно ВООБЩЕ — там глобальный запрос и есть наш,
// и шесть зелёных тестов врали хором, пока прогон не отдал вторую страницу,
// неотличимую от первой (В-167).
$request = Request::create('/api/sms/contacts?page=2', 'GET');
$request->setUserResolver(fn () => $this->user);
$body = json_decode(app(ClientSmsBaseController::class)->contacts($request)->getContent(), true);
expect($body['page'])->toBe(2);
$firstPageIds = ClientSmsContact::where('tenant_id', $this->tenant->id)
->orderByDesc('id')->limit(50)->pluck('id')->all();
expect(array_intersect(array_column($body['items'], 'id'), $firstPageIds))->toBeEmpty();
});
it('4.7 — в страницу не попадают чужие номера', function () {
Bus::fake();
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(60)])->assertOk();
$foreign = Tenant::factory()->create();
$foreignUser = User::factory()->create(['tenant_id' => $foreign->id]);
$this->actingAs($foreignUser);
$this->postJson('/api/sms/contacts', ['phones' => bazaPhones(5, 900000)])->assertOk();
// Чужой видит ТОЛЬКО свои пять — и «всего» у него тоже пять, а не 65.
$res = $this->getJson('/api/sms/contacts');
$res->assertOk()->assertJsonPath('total', 5);
expect($res->json('items'))->toHaveCount(5);
// А мы — свои шестьдесят.
$this->actingAs($this->user);
$this->getJson('/api/sms/contacts')->assertJsonPath('total', 60);
});
it('4.6 — без строки настроек потолок всё равно есть', function () {
// Строка настроек на стенде обычно есть (её заводит миграция), но код не имеет
// права на неё РАССЧИТЫВАТЬ: нет строки — потолок обязан остаться, иначе
// загрузка либо не ограничена вовсе, либо запрещена целиком.
ClientSmsSettings::query()->delete();
expect(app(ClientSmsUploadLimit::class)->max())->toBe(ClientSmsUploadLimit::DEFAULT);
});