tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); /** Селектор с настоящим МТС-роутером — маршрутизирует mts даже в реальном режиме. */ function realMtsSelectorInstance(): ClientSmsRecipientSelector { return new ClientSmsRecipientSelector( new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer), ); } it('preview (source=base) считает смету и НЕ создаёт кампанию', function () { ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']); ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'mts']); $response = $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'base', ]); $response->assertOk() ->assertJsonPath('segments', 1) ->assertJsonPath('sendable_count', 2); expect($response->json('estimated_cost_rub'))->toBeString() ->and($response->json('price_rub_per_sms'))->toBeString(); expect(ClientSmsCampaign::count())->toBe(0); }); it('uploadContacts сообщает, сколько номеров не распознали', function () { $res = $this->postJson('/api/sms/contacts', [ 'phones' => ['79990000001', 'мусор', '8 (999) 000-00-02', 'абвгд'], ]); $res->assertOk() ->assertJsonPath('added', 2) // 79990000001 + нормализованный 8(999)000-00-02 ->assertJsonPath('rejected', 2); // «мусор», «абвгд» }); it('uploadContacts возвращает примеры отброшенных номеров (не молча)', function () { $res = $this->postJson('/api/sms/contacts', [ 'phones' => ['79990000001', 'мусор', 'абвгд'], ]); $res->assertOk() ->assertJsonPath('added', 1) ->assertJsonPath('rejected', 2); expect($res->json('rejected_samples'))->toBeArray() ->toContain('мусор') ->toContain('абвгд'); }); it('uploadContacts запускает обогащение оператором через ДаДату', function () { Bus::fake(); $this->postJson('/api/sms/contacts', ['phones' => ['79990000001']])->assertOk(); Bus::assertDispatched( EnrichClientSmsContactsOperatorJob::class, fn ($job) => (fn () => $this->tenantId)->call($job) === $this->tenant->id, ); }); it('index возвращает баланс и заморозку кошелька (для заслонки «Отправить»)', function () { app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test'); $this->getJson('/api/sms/campaigns') ->assertOk() ->assertJsonPath('balance_rub', '100.00') ->assertJsonPath('frozen_rub', '0.00'); }); it('index без кошелька отдаёт нулевой баланс', function () { $this->getJson('/api/sms/campaigns') ->assertOk() ->assertJsonPath('balance_rub', '0.00') ->assertJsonPath('frozen_rub', '0.00'); }); it('source=deals без срока (audience_days) отклоняется — не рассылаем по всей истории', function () { // preview $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'deals', ])->assertStatus(422)->assertJsonValidationErrors('audience_days'); // store $this->postJson('/api/sms/campaigns', [ 'title' => 'Рассылка', 'body' => 'Здравствуйте!', 'source' => 'deals', ])->assertStatus(422)->assertJsonValidationErrors('audience_days'); expect(ClientSmsCampaign::count())->toBe(0); }); it('source=deals со сроком проходит валидацию', function () { $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'deals', 'audience_days' => 30, ])->assertOk(); }); it('2.8 — срок больше года не принимается, и человек читает почему', function () { // Предпросмотр $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'deals', 'audience_days' => 400, ])->assertStatus(422) ->assertJsonPath('errors.audience_days.0', 'Больше 365 дней нельзя — возьмите срок покороче.'); // И создание рассылки — иначе ограничение обходится в один запрос мимо экрана. $this->postJson('/api/sms/campaigns', [ 'title' => 'Рассылка', 'body' => 'Здравствуйте!', 'source' => 'deals', 'audience_days' => 400, ])->assertStatus(422) ->assertJsonPath('errors.audience_days.0', 'Больше 365 дней нельзя — возьмите срок покороче.'); expect(ClientSmsCampaign::count())->toBe(0); }); it('2.8 — ровно 365 дней принимается: границу не съели', function () { // Контрольная проверка к предыдущему тесту: «не прошло» что-то доказывает // только рядом с «прошло» (В-54). Заодно ловит ошибку на единицу. $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'deals', 'audience_days' => 365, ])->assertOk(); }); it('store (sandbox) создаёт кампанию queued и ставит джоб в очередь; заморозки нет', function () { config(['services.sms.sandbox' => true]); Queue::fake(); ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']); ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'mts']); $response = $this->postJson('/api/sms/campaigns', [ 'title' => 'Рассылка А', 'body' => 'Здравствуйте!', 'source' => 'base', ]); $response->assertCreated() ->assertJsonPath('status', ClientSmsCampaign::STATUS_QUEUED); $id = $response->json('id'); $this->assertDatabaseHas('client_sms_campaigns', [ 'id' => $id, 'tenant_id' => $this->tenant->id, 'status' => ClientSmsCampaign::STATUS_QUEUED, ]); Queue::assertPushed(SendClientSmsCampaignJob::class); // Sandbox: заморозки нет — кошелёк не создан. expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse(); }); it('store (реальный режим, денег не хватает) → 409 и кампания откачена', function () { config(['services.sms.sandbox' => false]); Queue::fake(); // Роутер реального режима сам по себе mts не поднимет (нет токена) — подменяем // селектор на реальный МТС, чтобы получатели прошли и смета была > 0. $this->app->instance(ClientSmsRecipientSelector::class, realMtsSelectorInstance()); // Кошелёк есть, но денег мало (смета заведомо больше). app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test'); ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'mts']); ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'mts']); $response = $this->postJson('/api/sms/campaigns', [ 'title' => 'Рассылка без денег', 'body' => 'Здравствуйте!', 'source' => 'base', ]); $response->assertStatus(409); // Кампания откачена — сироты нет. expect(ClientSmsCampaign::where('tenant_id', $this->tenant->id)->count())->toBe(0); Queue::assertNothingPushed(); }); it('изоляция тенантов: чужие кампании не видны и show чужой → 404', function () { $mine = ClientSmsCampaign::create([ 'tenant_id' => $this->tenant->id, 'title' => 'Моя', 'body' => 'x', 'sender_name' => 'liderra.ru', 'source' => 'base', 'status' => ClientSmsCampaign::STATUS_QUEUED, 'segments' => 1, 'planned_count' => 0, 'total_sms' => 0, 'price_rub_per_sms' => '9.00', 'estimated_cost_rub' => '0.00', ]); $tenantB = Tenant::factory()->create(); $foreign = ClientSmsCampaign::create([ 'tenant_id' => $tenantB->id, 'title' => 'Чужая', 'body' => 'x', 'sender_name' => 'liderra.ru', 'source' => 'base', 'status' => ClientSmsCampaign::STATUS_QUEUED, 'segments' => 1, 'planned_count' => 0, 'total_sms' => 0, 'price_rub_per_sms' => '9.00', 'estimated_cost_rub' => '0.00', ]); $index = $this->getJson('/api/sms/campaigns'); $index->assertOk(); $ids = collect($index->json('campaigns'))->pluck('id')->all(); expect($ids)->toContain($mine->id) ->and($ids)->not->toContain($foreign->id); $this->getJson("/api/sms/campaigns/{$foreign->id}")->assertStatus(404); }); it('контакты: загрузка, список только своих, удаление, изоляция тенанта', function () { $upload = $this->postJson('/api/sms/contacts', [ 'phones' => ['79990000001', '89990000002', 'мусор'], ]); $upload->assertOk(); // 2 валидных (мусор пропущен). expect($upload->json('added'))->toBe(2); // 🪤 Ответ — объект `{items, max_upload_phones}`, а не голый массив: экрану // нужен потолок загрузки ДО того, как человек нажмёт «Загрузить» (строка 4.6). $list = $this->getJson('/api/sms/contacts'); $list->assertOk(); expect($list->json('items'))->toHaveCount(2); // Чужой тенант не видит наши контакты. $tenantB = Tenant::factory()->create(); $userB = User::factory()->create(['tenant_id' => $tenantB->id]); $this->actingAs($userB); expect($this->getJson('/api/sms/contacts')->json('items'))->toHaveCount(0); // Возвращаемся к своему и удаляем один. $this->actingAs($this->user); $contactId = ClientSmsContact::where('tenant_id', $this->tenant->id)->first()->id; $this->deleteJson("/api/sms/contacts/{$contactId}")->assertOk(); expect($this->getJson('/api/sms/contacts')->json('items'))->toHaveCount(1); }); it('шаблоны: CRUD со скоупом тенанта', function () { $create = $this->postJson('/api/sms/templates', ['title' => 'Привет', 'body' => 'Здравствуйте!']); $create->assertCreated(); $tplId = $create->json('id'); $this->getJson('/api/sms/templates')->assertOk()->assertJsonCount(1); $this->patchJson("/api/sms/templates/{$tplId}", ['title' => 'Обновлён']) ->assertOk()->assertJsonPath('title', 'Обновлён'); // Чужой тенант не видит/не трогает. $tenantB = Tenant::factory()->create(); $userB = User::factory()->create(['tenant_id' => $tenantB->id]); $this->actingAs($userB); $this->getJson('/api/sms/templates')->assertOk()->assertJsonCount(0); $this->patchJson("/api/sms/templates/{$tplId}", ['title' => 'Взлом'])->assertStatus(404); $this->actingAs($this->user); $this->deleteJson("/api/sms/templates/{$tplId}")->assertOk(); expect(ClientSmsTemplate::where('tenant_id', $this->tenant->id)->count())->toBe(0); }); it('4.8 — номер принимается в любом виде: +7, 8, пробелы, скобки, дефисы', function () { // 🪤 Вся строка 4.8 — это одна лишняя проверка в правилах заказа // (`'phones.*' => 'string|size:11'`): она требовала РОВНО 11 знаков и отбивала // «+79991234567» ошибкой 422 ещё до разборщика номеров, который читает такое // прекрасно. Умение было — ему мешала проверка. $response = $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'manual', 'phones' => [ '+79990000001', '8 999 000 00 02', '(999) 000-00-03', '7999-000-00-04', ], ]); $response->assertOk() ->assertJsonPath('sendable_count', 4) ->assertJsonPath('rejected_count', 0); }); it('4.8 — непонятые номера не пропадают молча: человек видит их образцами', function () { // Строка листа так и говорит: «непонятые возвращаются образцами». Молчаливая // пропажа хуже отказа — человек считает, что рассылка ушла на весь список. $response = $this->postJson('/api/sms/preview', [ 'body' => 'Здравствуйте!', 'source' => 'manual', 'phones' => ['+79990000005', 'абвгд', '123'], ]); $response->assertOk() ->assertJsonPath('sendable_count', 1) ->assertJsonPath('rejected_count', 2) ->assertJsonPath('rejected_samples', ['абвгд', '123']); }); it('4.8 — при заказе рассылки номера тоже принимаются в любом виде', function () { // Пара к предпросмотру: правило проверки ОДНО на оба адреса, но заказ ещё и // пишет снимок — там номер обязан лежать уже в едином виде «79…». app(AdWalletService::class)->topup((int) $this->tenant->id, '1000.00', null, 'тест'); Queue::fake(); $response = $this->postJson('/api/sms/campaigns', [ 'title' => 'Свой список', 'source' => 'manual', 'body' => 'Здравствуйте!', 'phones' => ['+79990000001', '8 999 000 00 02', 'мусор'], ]); $response->assertCreated() ->assertJsonPath('planned_count', 2) ->assertJsonPath('rejected_count', 1) ->assertJsonPath('rejected_samples', ['мусор']); expect(ClientSmsCampaignPhone::where('campaign_id', $response->json('id')) ->orderBy('phone')->pluck('phone')->all()) ->toBe(['79990000001', '79990000002']); });