475], baseUrl: BEE_URL, naming: 'Liderra.ru', token: $token, ); } function bee_message(string $sender = 'Liderra.ru', int $segments = 1): SmsOutgoing { return new SmsOutgoing( phone: '79990000001', body: 'Здравствуйте!', senderName: $sender, operator: 'beeline', segments: $segments, ); } /** Успешный ответ платформы на отправку. */ function bee_ok(string $id = '508909732338141600334', string $count = '1'): array { return [ 'actions' => [[ 'sms_group_id' => '894323467546261497508434', 'id' => $id, 'sms_type' => 'SENDSMS', 'phone' => '+79990000001', 'sms_res_count' => $count, 'message' => 'Здравствуйте!', 'action' => 'post_sms_action', ]], 'agt_id' => '47309602', 'date_report' => '15.10.2022 18:42:59', ]; } /** Отправить и вернуть пойманное исключение; прошло без ошибки — провалить тест. */ function bee_thrown(array $body, int $status = 200): SmsSendException { Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response($body, $status)]); try { bee_provider()->send(bee_message()); } catch (SmsSendException $e) { return $e; } throw new RuntimeException('ожидалось SmsSendException, но отправка прошла без ошибки'); } it('называется beeline и обслуживает заявленные сети', function () { expect(bee_provider()->key())->toBe('beeline') ->and(bee_provider()->servesOperators())->toBe(['beeline']); }); it('успешная отправка возвращает номер сообщения и цену', function () { Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(), 200)]); $result = bee_provider()->send(bee_message()); expect($result->providerMessageId)->toBe('508909732338141600334') ->and($result->segments)->toBe(1) ->and($result->costKopecks)->toBe(475); Http::assertSent(function ($request) { return $request->url() === BEE_URL && $request->method() === 'POST' && $request['user'] === '1834321' && $request['pass'] === 'secret' && $request['action'] === 'post_sms' && $request['sender'] === 'Liderra.ru' && $request['message'] === 'Здравствуйте!' // Номер уходит строкой в международном виде — так в примерах Билайна. && $request['target'] === '+79990000001'; }); }); it('за деньги отвечает счётчик частей, названный платформой, а не наша оценка', function () { // Мы посчитали 1 часть, платформа насчитала 2 — платить придётся за 2. Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(count: '2'), 200)]); $result = bee_provider()->send(bee_message(segments: 1)); expect($result->segments)->toBe(2) ->and($result->costKopecks)->toBe(950); }); it('по ключу ходит без логина и пароля, с заголовком X-ApiKey', function () { Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(), 200)]); bee_provider(token: 'GvEqzI1TSmw7bDn')->send(bee_message()); Http::assertSent(function ($request) { return $request->hasHeader('X-ApiKey', 'ApiKey GvEqzI1TSmw7bDn') // 🔴 Вместе с ключом логин с паролем слать НЕЛЬЗЯ — платформа откажет. && ! isset($request['user']) && ! isset($request['pass']); }); }); it('чужое имя отправителя не подменяет, а своё приводит к согласованному написанию', function () { Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(), 200)]); bee_provider()->send(bee_message(sender: 'liderra.ru')); bee_provider()->send(bee_message(sender: 'ChuzhoeImya')); $otpravleno = []; Http::assertSent(function ($request) use (&$otpravleno) { $otpravleno[] = $request['sender']; return true; }); expect($otpravleno)->toBe(['Liderra.ru', 'ChuzhoeImya']); }); it('отказ платформы без номера сообщения отправкой не считает', function () { $e = bee_thrown(['error' => ['code' => '20200', 'message' => 'invalid request']]); expect($e->getMessage())->toContain('20200'); }); it('неодобренное оператором имя отправителя — отказ окончательный', function () { // 20230: «Отправитель X не одобрен на стороне оператора Y». Повтор не поможет, // пока имя не согласуют — держать такое в очереди значит копить мусор. $e = bee_thrown(['error' => ['code' => '20230', 'message' => 'Отправитель не одобрен']]); expect($e->terminal)->toBeTrue(); }); it('упёрлись в суточный предел — отказ ВРЕМЕННЫЙ, завтра пройдёт', function () { $e = bee_thrown(['error' => ['code' => '20280', 'message' => 'Достигнут суточный лимит']]); expect($e->terminal)->toBeFalse(); }); // 🪤 Две подделки сети в ОДНОМ тесте ставить нельзя: второй вызов Http::fake не // заменяет первую, а добавляется к ней, и срабатывает всё равно первая. Поэтому // каждый код ответа проверяем отдельным тестом. it('сбой на стороне Билайна — отказ временный', function () { expect(bee_thrown([], 503)->terminal)->toBeFalse(); }); it('нас не пустили — отказ окончательный, повтор не поможет', function () { expect(bee_thrown([], 401)->terminal)->toBeTrue(); }); it('не достучались — отказ временный', function () { Http::fake(function () { throw new ConnectionException('cURL error 28'); }); try { bee_provider()->send(bee_message()); throw new RuntimeException('ожидалось SmsSendException'); } catch (SmsSendException $e) { expect($e->terminal)->toBeFalse(); } }); it('судьбу спрашивает пачкой и разбирает доставленное', function () { Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response([ 'actions' => [[ 'sms_id' => '508909732338141600334', 'target' => '79990000001', 'sms_count' => '2', 'stc_code' => 'delivered', 'close_time' => '15.10.2022 18:42:59 ', 'status' => 'Сообщение доставлено', 'action' => 'status_action', ]], ], 200)]); $reports = bee_provider()->fetchDelivery(['508909732338141600334']); expect($reports)->toHaveCount(1) ->and($reports[0]->providerMessageId)->toBe('508909732338141600334') ->and($reports[0]->state)->toBe(SmsDeliveryState::DELIVERED) ->and($reports[0]->phone)->toBe('79990000001') ->and($reports[0]->partCount)->toBe(2) ->and($reports[0]->deliveredAt)->not->toBeNull(); Http::assertSent(fn ($request) => $request['action'] === 'status' && $request['sms_id'] === '508909732338141600334'); }); it('незнакомое слово о судьбе не угадывает', function () { Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response([ 'actions' => [[ 'sms_id' => '1', 'stc_code' => 'nechto-novoe', ]], ], 200)]); $reports = bee_provider()->fetchDelivery(['1']); // 🔴 Ноль вместо догадки: за «не доставлено» клиенту возвращаются деньги. expect($reports[0]->state)->toBeNull() ->and($reports[0]->rawState)->toBe('nechto-novoe'); }); it('принятое оператором и отклонённое им разводит по разным судьбам', function () { $slova = ['accepted', 'rejected', 'undeliverable', 'aborted']; // 🪤 Ровно ОДНА подделка на тест, ответы по очереди: повторный Http::fake // не заменяет прежний, и все опросы получали бы первый ответ. $ochered = Http::fakeSequence('a2p-sms-https.beeline.ru/*'); foreach ($slova as $slovo) { $ochered->push(['actions' => [['sms_id' => '1', 'stc_code' => $slovo]]], 200); } $sudby = []; foreach ($slova as $slovo) { $sudby[$slovo] = bee_provider()->fetchDelivery(['1'])[0]->state; } expect($sudby['accepted'])->toBe(SmsDeliveryState::SENDING) // Оператор отклонил и отменённое пользователем — сообщение НЕ ушло, // платить за это клиент не должен. ->and($sudby['rejected'])->toBe(SmsDeliveryState::NOT_SENT) ->and($sudby['aborted'])->toBe(SmsDeliveryState::NOT_SENT) // Абонент недоступен — ушло, но не дошло. ->and($sudby['undeliverable'])->toBe(SmsDeliveryState::NOT_DELIVERED); });