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, ); } 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('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); $list = $this->getJson('/api/sms/contacts'); $list->assertOk(); expect($list->json())->toHaveCount(2); // Чужой тенант не видит наши контакты. $tenantB = Tenant::factory()->create(); $userB = User::factory()->create(['tenant_id' => $tenantB->id]); $this->actingAs($userB); expect($this->getJson('/api/sms/contacts')->json())->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())->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); });