Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def test_static_contract_is_openapi_31_and_redacted() -> None:
|
||||
contract = yaml.safe_load(
|
||||
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert contract["openapi"] == "3.1.0"
|
||||
paths = contract["paths"]
|
||||
assert "/internal/sms/v1/send" in paths
|
||||
assert "/internal/sms/v1/messages/{sms_message_id}" in paths
|
||||
assert "/callbacks/idgtl/sms" in paths
|
||||
message_fields = contract["components"]["schemas"]["Message"]["properties"]
|
||||
assert {"phone_e164", "body_rendered", "substitutions"}.isdisjoint(message_fields)
|
||||
assert "idgtlDeliveryStatus" in contract["webhooks"]
|
||||
|
||||
|
||||
def test_send_contract_distinguishes_new_and_replayed_order() -> None:
|
||||
contract = yaml.safe_load(
|
||||
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
responses = contract["paths"]["/internal/sms/v1/send"]["post"]["responses"]
|
||||
assert {"200", "202", "401", "409", "422", "429", "503"} <= responses.keys()
|
||||
@@ -0,0 +1,35 @@
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.domain import DomainError
|
||||
from app.main import basic_auth, bearer_auth
|
||||
|
||||
|
||||
def request_with(authorization: str):
|
||||
settings = SimpleNamespace(
|
||||
service_token=SecretStr("s" * 43),
|
||||
callback_username=SecretStr("callback-user"),
|
||||
callback_password=SecretStr("callback-password"),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
headers={"Authorization": authorization},
|
||||
app=SimpleNamespace(state=SimpleNamespace(settings=settings)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_api_requires_exact_bearer_token() -> None:
|
||||
await bearer_auth(request_with(f"Bearer {'s' * 43}"))
|
||||
with pytest.raises(DomainError) as error:
|
||||
await bearer_auth(request_with("Bearer wrong"))
|
||||
assert error.value.code == "unauthorized"
|
||||
|
||||
|
||||
def test_callback_requires_exact_basic_credentials() -> None:
|
||||
encoded = base64.b64encode(b"callback-user:callback-password").decode()
|
||||
basic_auth(request_with(f"Basic {encoded}"))
|
||||
with pytest.raises(DomainError):
|
||||
basic_auth(request_with("Basic invalid"))
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
|
||||
from app.db import DeliveryStatus
|
||||
from app.domain import (
|
||||
DomainError,
|
||||
delivery_transition,
|
||||
normalize_phone,
|
||||
render_template,
|
||||
request_fingerprint,
|
||||
sms_parts,
|
||||
)
|
||||
|
||||
|
||||
def test_phone_is_canonical_and_masked() -> None:
|
||||
e164, digits, masked = normalize_phone("+79001234567")
|
||||
assert e164 == "+79001234567"
|
||||
assert digits == "79001234567"
|
||||
assert masked == "+7******4567"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phone", ["79001234567", "+012345678", "+7900", "+7999999999999999"])
|
||||
def test_invalid_phone_is_rejected(phone: str) -> None:
|
||||
with pytest.raises(DomainError) as error:
|
||||
normalize_phone(phone)
|
||||
assert error.value.code == "sms_request_invalid"
|
||||
|
||||
|
||||
def test_strict_template_render() -> None:
|
||||
result = render_template(
|
||||
"Код входа: {code}. Действителен {ttl_min} мин.",
|
||||
["code", "ttl_min"],
|
||||
{"code": "482193", "ttl_min": 1},
|
||||
1,
|
||||
)
|
||||
assert result == "Код входа: 482193. Действителен 1 мин."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"substitutions",
|
||||
[
|
||||
{"code": "482193"},
|
||||
{"code": "482193", "ttl_min": 1, "extra": "forbidden"},
|
||||
],
|
||||
)
|
||||
def test_template_rejects_placeholder_mismatch(substitutions) -> None:
|
||||
with pytest.raises(DomainError):
|
||||
render_template(
|
||||
"Код: {code}; TTL: {ttl_min}",
|
||||
["code", "ttl_min"],
|
||||
substitutions,
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
def test_template_rejects_format_expressions() -> None:
|
||||
with pytest.raises(DomainError):
|
||||
render_template("{code!r}", ["code"], {"code": "123456"}, 1)
|
||||
|
||||
|
||||
def test_sms_parts_supports_gsm_and_unicode() -> None:
|
||||
assert sms_parts("A" * 160) == 1
|
||||
assert sms_parts("A" * 161) == 2
|
||||
assert sms_parts("Я" * 70) == 1
|
||||
assert sms_parts("Я" * 71) == 2
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical() -> None:
|
||||
first = request_fingerprint({"b": 2, "a": {"y": 2, "x": 1}})
|
||||
second = request_fingerprint({"a": {"x": 1, "y": 2}, "b": 2})
|
||||
assert first == second
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current", "incoming", "expected"),
|
||||
[
|
||||
(DeliveryStatus.UNKNOWN, "sent", DeliveryStatus.SENT),
|
||||
(DeliveryStatus.SENT, "delivered", DeliveryStatus.DELIVERED),
|
||||
(DeliveryStatus.DELIVERED, "sent", DeliveryStatus.DELIVERED),
|
||||
(DeliveryStatus.UNDELIVERED, "sent", DeliveryStatus.UNDELIVERED),
|
||||
(DeliveryStatus.DELIVERED, "unsent", DeliveryStatus.DELIVERED),
|
||||
(DeliveryStatus.UNKNOWN, "bogus", None),
|
||||
],
|
||||
)
|
||||
def test_delivery_status_is_monotonic(current, incoming, expected) -> None:
|
||||
assert delivery_transition(current, incoming) == expected
|
||||
@@ -0,0 +1,90 @@
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.db import SendStatus
|
||||
from app.provider import IdgtlConfig, callback_url_with_credentials, classify_response
|
||||
|
||||
|
||||
def response(status: int, payload=None) -> httpx.Response:
|
||||
request = httpx.Request("POST", "https://direct.example/api/v1/message")
|
||||
if payload is None:
|
||||
return httpx.Response(status, request=request)
|
||||
return httpx.Response(status, json=payload, request=request)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 402, 403, 422])
|
||||
def test_explicit_business_rejections_are_not_retried(status: int) -> None:
|
||||
result = classify_response(response(status), "message-id")
|
||||
assert result.send_status == SendStatus.REJECTED
|
||||
assert result.retry_safe is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
||||
def test_ambiguous_http_results_are_uncertain(status: int) -> None:
|
||||
result = classify_response(response(status), "message-id")
|
||||
assert result.send_status == SendStatus.UNCERTAIN
|
||||
assert result.retry_safe is False
|
||||
|
||||
|
||||
def test_exact_success_contract() -> None:
|
||||
message_uuid = str(uuid.uuid4())
|
||||
result = classify_response(
|
||||
response(
|
||||
200,
|
||||
{
|
||||
"errors": False,
|
||||
"response": [
|
||||
{
|
||||
"code": 201,
|
||||
"messageUuid": message_uuid,
|
||||
"externalMessageId": "message-id",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
"message-id",
|
||||
)
|
||||
assert result.send_status == SendStatus.ACCEPTED
|
||||
assert result.message_uuid == message_uuid
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"errors": True, "response": []},
|
||||
{"errors": False, "response": []},
|
||||
{"errors": False, "response": [{"code": 200}]},
|
||||
{
|
||||
"errors": False,
|
||||
"response": [
|
||||
{
|
||||
"code": 201,
|
||||
"messageUuid": str(uuid.uuid4()),
|
||||
"externalMessageId": "wrong",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_malformed_200_is_rejected_contract_violation(payload) -> None:
|
||||
result = classify_response(response(200, payload), "message-id")
|
||||
assert result.send_status == SendStatus.REJECTED
|
||||
assert result.contract_violation is True
|
||||
|
||||
|
||||
def test_callback_credentials_are_url_encoded() -> None:
|
||||
config = IdgtlConfig(
|
||||
base_url="https://direct.example",
|
||||
api_key="api-key",
|
||||
callback_url="https://tohin.ru/callbacks/idgtl/sms",
|
||||
callback_username="user@example",
|
||||
callback_password="p:a/ss", # noqa: S106 - synthetic URL-encoding fixture
|
||||
connect_timeout_ms=3000,
|
||||
request_timeout_ms=70000,
|
||||
callback_enabled=True,
|
||||
)
|
||||
assert callback_url_with_credentials(config) == (
|
||||
"https://user%40example:p%3Aa%2Fss@tohin.ru/callbacks/idgtl/sms"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain import DomainError
|
||||
from app.schemas import CallbackItem, SendRequest
|
||||
from app.service import validate_otp_request
|
||||
|
||||
|
||||
def valid_send(**overrides) -> SendRequest:
|
||||
payload = {
|
||||
"idempotency_key": "keycloak:challenge:01JABCDEF",
|
||||
"template_code": "auth_otp",
|
||||
"locale": "ru",
|
||||
"phone_e164": "+79001234567",
|
||||
"substitutions": {"code": "482193", "ttl_min": "1"},
|
||||
"customer_ref": "01JABCDEF",
|
||||
"message_ttl_sec": 60,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return SendRequest.model_validate(payload)
|
||||
|
||||
|
||||
def test_send_request_is_strict() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
valid_send(extra="forbidden")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("substitutions", "ttl"),
|
||||
[
|
||||
({"code": "12ab", "ttl_min": "1"}, 60),
|
||||
({"code": "123456", "ttl_min": "2"}, 60),
|
||||
({"code": "123456", "ttl_min": "1"}, 61),
|
||||
],
|
||||
)
|
||||
def test_otp_substitutions_match_ttl(substitutions, ttl) -> None:
|
||||
with pytest.raises(DomainError):
|
||||
validate_otp_request(valid_send(substitutions=substitutions, message_ttl_sec=ttl))
|
||||
|
||||
|
||||
def test_callback_accepts_provider_camel_case() -> None:
|
||||
item = CallbackItem.model_validate(
|
||||
{
|
||||
"channelType": "SMS",
|
||||
"messageUuid": "provider-id",
|
||||
"externalMessageId": "internal-id",
|
||||
"callbackEvent": "delivered",
|
||||
"status": "delivered",
|
||||
"statusTime": "2026-07-22T12:00:00Z",
|
||||
}
|
||||
)
|
||||
assert item.channel_type == "SMS"
|
||||
assert item.status_time.tzinfo is not None
|
||||
Reference in New Issue
Block a user