250], baseUrl: 'https://target.t2.ru/api/v2', naming: 'Liderra', ); } function t2_message(string $sender = 'Liderra', int $segments = 1): SmsOutgoing { return new SmsOutgoing( phone: '79990000001', body: 'Здравствуйте!', senderName: $sender, operator: 'tele2', segments: $segments, ); } /** Отправить и вернуть пойманное SmsSendException; если исключения не было — провалить тест. */ function t2_thrown(): SmsSendException { try { t2_provider()->send(t2_message()); } catch (SmsSendException $e) { return $e; } throw new RuntimeException('ожидалось SmsSendException, но отправка прошла без ошибки'); } it('обслуживает только Теле2', function () { expect(t2_provider()->servesOperators())->toBe(['tele2']) ->and(t2_provider()->key())->toBe('tele2'); }); it('успешная отправка возвращает номер сообщения и цену', function () { Http::fake(['target.t2.ru/*' => Http::response( ['result' => ['uid' => 'message-id-VvkmLHkfhCX9'], 'status' => 'ok'], 200, )]); $result = t2_provider()->send(t2_message(segments: 2)); expect($result->providerMessageId)->toBe('message-id-VvkmLHkfhCX9') ->and($result->segments)->toBe(2) ->and($result->costKopecks)->toBe(500); Http::assertSent(function ($request) { return $request->url() === 'https://target.t2.ru/api/v2/send_message' && $request->method() === 'POST' // Номер уходит ЧИСЛОМ по формату 7nnnNNNnnNN — так в документации t2. && $request['msisdn'] === 79990000001 && $request['shortcode'] === 'Liderra' && $request['text'] === 'Здравствуйте!' && $request->hasHeader('Authorization', 'Basic '.base64_encode('liderra_api:secret')); }); }); it('отказ с кодом 200 в теле — это отказ, а не успех', function () { // 🪤 t2 отвечает 200 даже на отказ: судить надо по полю status, а не по коду HTTP. Http::fake(['target.t2.ru/*' => Http::response( ['reason' => 'invalid_field_shortcode', 'status' => 'error'], 200, )]); $e = t2_thrown(); expect($e->getMessage())->toContain('invalid_field_shortcode') ->and($e->terminal)->toBeTrue(); }); it('сбой на стороне t2 — повтор осмыслен', function () { Http::fake(['target.t2.ru/*' => Http::response('', 502)]); expect(t2_thrown()->terminal)->toBeFalse(); }); it('связь оборвалась — это отказ канала, а не падение джоба', function () { Http::fake(fn () => throw new ConnectionException('Connection timed out')); $e = t2_thrown(); expect($e->terminal)->toBeFalse() ->and($e->getMessage())->toContain('Теле2 недоступен'); }); it('внутренняя ошибка t2 — повтор осмыслен, в отличие от кривого запроса', function () { // system_error = «не смогли сохранить сообщение», это не наша вина: через // минуту получится. Остальные reason'ы — наши, повторять их бесполезно. Http::fake(['target.t2.ru/*' => Http::response( ['reason' => 'system_error', 'status' => 'error'], 200, )]); expect(t2_thrown()->terminal)->toBeFalse(); }); // ─── Судьба сообщений: «дошло или нет» (строки листа 5.1–5.2) ──────────────── /** * Ответ t2 на опрос об одном сообщении — форма из документации «t2 HTTP API v2». * * @return array */ function t2_pollBody( string $status = 'delivered', string $uid = 'message-id-VvkmLHkfhCX9', mixed $pduCount = 2, ): array { return [ 'result' => [ 'created_at' => 1574087003, 'msisdn' => 79990000001, 'pdu_count' => $pduCount, 'status' => $status, 'text' => 'Здравствуйте!', 'uid' => $uid, 'updated_at' => 1574087903, ], 'status' => 'ok', ]; } it('за один заход спрашивает про ОДНО сообщение — пакетного запроса у t2 нет', function () { expect(t2_provider()->deliveryBatchSize())->toBe(1); }); it('спрашивает судьбу по номеру сообщения и понимает ответ', function () { Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(), 200)]); $reports = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']); expect($reports)->toHaveCount(1); $report = $reports[0]; expect($report->providerMessageId)->toBe('message-id-VvkmLHkfhCX9') ->and($report->phone)->toBe('79990000001') ->and($report->state)->toBe(SmsDeliveryState::DELIVERED) ->and($report->rawState)->toBe('delivered') ->and($report->partCount)->toBe(2) // Время приходит ЧИСЛОМ (UNIX), а не строкой даты. ->and($report->happenedAt?->timestamp)->toBe(1574087903) ->and($report->deliveredAt?->timestamp)->toBe(1574087903); Http::assertSent(function ($request) { // Номер сообщения уже несёт приставку message-id- — второй раз её не клеим. return $request->url() === 'https://target.t2.ru/api/v2/send_message/message-id-VvkmLHkfhCX9' && $request->method() === 'GET' && $request->hasHeader('Authorization', 'Basic '.base64_encode('liderra_api:secret')); }); }); it('про пустой список не спрашивает вовсе', function () { Http::fake(); expect(t2_provider()->fetchDelivery([]))->toBe([]); Http::assertNothingSent(); }); it('спрашивает по очереди про каждое сообщение', function () { Http::fake([ 'target.t2.ru/api/v2/send_message/message-id-A' => Http::response(t2_pollBody(uid: 'message-id-A'), 200), 'target.t2.ru/api/v2/send_message/message-id-B' => Http::response( t2_pollBody(status: 'failed', uid: 'message-id-B'), 200 ), ]); $reports = t2_provider()->fetchDelivery(['message-id-A', 'message-id-B']); expect($reports)->toHaveCount(2) ->and($reports[0]->state)->toBe(SmsDeliveryState::DELIVERED) ->and($reports[1]->state)->toBe(SmsDeliveryState::NOT_DELIVERED); Http::assertSentCount(2); }); it('переводит слова t2 в судьбу портала', function (string $raw, ?string $expected) { Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(status: $raw), 200)]); $report = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]; expect($report->state)->toBe($expected) ->and($report->rawState)->toBe($raw); })->with([ ['delivered', SmsDeliveryState::DELIVERED], ['awaiting_report', SmsDeliveryState::SENDING], ['failed', SmsDeliveryState::NOT_DELIVERED], // Оператор ПРИНЯЛ, но не отправил — за такое клиент платить не должен (В-212). ['insufficient_balance', SmsDeliveryState::NOT_SENT], ['rejected', SmsDeliveryState::NOT_SENT], // Незнакомое слово НЕ угадываем: за «не доставлено» возвращаются деньги (В-198). ['какое_то_новое_слово', null], ]); it('время доставки ставит только у дошедшего', function () { Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(status: 'awaiting_report'), 200)]); $report = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]; expect($report->deliveredAt)->toBeNull() // Момент последней перемены знаем всегда — он и есть happenedAt. ->and($report->happenedAt?->timestamp)->toBe(1574087903); }); it('пустое число частей не выдумывает', function () { Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(pduCount: null), 200)]); expect(t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]->partCount)->toBeNull(); }); it('сбой при опросе — повтор осмыслен, судьбу можно переспросить позже', function () { Http::fake(['target.t2.ru/*' => Http::response('', 500)]); try { t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']); throw new RuntimeException('ожидалось SmsSendException'); } catch (SmsSendException $e) { expect($e->terminal)->toBeFalse(); } }); it('отказ t2 при опросе — тоже не окончательный', function () { // 🪤 И здесь отказ приезжает с кодом 200: судить по полю status. Http::fake(['target.t2.ru/*' => Http::response(['reason' => 'wrong_ip', 'status' => 'error'], 200)]); try { t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']); throw new RuntimeException('ожидалось SmsSendException'); } catch (SmsSendException $e) { expect($e->terminal)->toBeFalse() ->and($e->getMessage())->toContain('wrong_ip'); } }); it('понимает обратный звонок t2 — он приходит строками в адресе, а не письмом', function () { // t2 зовёт нас так: ...?id=&status=&parts= $reports = t2_provider()->parseDeliveryPayload([ 'id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered', 'parts' => '2', ]); expect($reports)->toHaveCount(1); $report = $reports[0]; expect($report->providerMessageId)->toBe('message-id-VvkmLHkfhCX9') ->and($report->state)->toBe(SmsDeliveryState::DELIVERED) ->and($report->rawState)->toBe('delivered') ->and($report->partCount)->toBe(2) // Номера в звонке нет — выдумывать нечего, и для поиска строки он не нужен. ->and($report->phone)->toBe(''); }); it('обратный звонок без числа частей — так бывает при отказе', function () { // parts не передаётся при rejected и insufficient_balance. $report = t2_provider()->parseDeliveryPayload([ 'id' => 'message-id-VvkmLHkfhCX9', 'status' => 'rejected', 'err' => 'blacklist', ])[0]; expect($report->partCount)->toBeNull() ->and($report->state)->toBe(SmsDeliveryState::NOT_SENT); }); it('слово «ok» из конверта ответа за судьбу НЕ принимает', function () { // 🪤 В ответе на опрос поле status встречается ДВАЖДЫ: снаружи это «запрос // удался», внутри — судьба сообщения. Спутать их значит объявить доставленным // всё подряд. $reports = t2_provider()->parseDeliveryPayload(t2_pollBody(status: 'failed')); expect($reports)->toHaveCount(1) ->and($reports[0]->state)->toBe(SmsDeliveryState::NOT_DELIVERED) ->and($reports[0]->rawState)->toBe('failed'); }); it('непонятную бумагу не разбирает — ноль записей, а не догадка', function () { expect(t2_provider()->parseDeliveryPayload(['events_info' => [['x' => 1]]]))->toBe([]) ->and(t2_provider()->parseDeliveryPayload([]))->toBe([]) // Есть слово, но нет номера сообщения — правит нечего. ->and(t2_provider()->parseDeliveryPayload(['status' => 'delivered']))->toBe([]); }); /** * 🔴 Имена отправителей регистрируются У КАЖДОГО ОПЕРАТОРА ОТДЕЛЬНО, и написание * бывает разным: у МТС согласовано `liderra.ru`, у t2 — `Liderra.ru`. * * Клиентская рассылка несёт ОДНО имя на всех получателей (`campaign.sender_name`), * и для t2 оно приезжает в чужом написании. Живой опыт на бою 04.08.2026: * `liderra.ru` → отказ `invalid_source_address`, `Liderra.ru` → принято. * * Поэтому канал приводит написание к своему — но ТОЛЬКО когда это то же самое имя, * отличающееся регистром. Чужое имя не трогаем: подменить его значило бы соврать * про отправителя. */ it('имя отправителя приводит к написанию, согласованному с t2', function () { Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); t2_provider()->send(t2_message(sender: 'liderra')); Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra'); }); it('точное совпадение имени не трогает', function () { Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); t2_provider()->send(t2_message(sender: 'Liderra')); Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra'); }); it('чужое имя отправителя НЕ подменяет', function () { // Когда у клиента появится своё согласованное имя, канал обязан слать его, // а не наше. Отказ t2 в этом случае — честный ответ, а не повод врать. Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); t2_provider()->send(t2_message(sender: 'ChuzhoeImya')); Http::assertSent(fn ($request) => $request['shortcode'] === 'ChuzhoeImya'); }); it('пустое имя заменяет своим', function () { Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); t2_provider()->send(t2_message(sender: '')); Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra'); });