feat: seven-send 0.1.0 — Library + CLI für SMS und TTS-Anrufe via seven.io
Wiederverwendbarer Baustein (eigenes Paket), nutzbar in beliebigen Programmen,
Cron-Jobs und Alerts:
- client.py: synchroner SevenClient.send_sms / send_voice (Voice als SSML,
XML-escaped); Result mit ok/code/price/error, best-effort Fehlerbehandlung,
dry_run-Modus
- cli.py: `seven-send sms|voice` mit --dry-run, --json, sauberen Exit-Codes;
API-Key aus --api-key oder $SEVEN_API_KEY
- src-Layout, pyproject (console_script seven-send), MIT-Lizenz
- 12 Tests (Client + CLI), httpx gemockt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:33:12 +02:00
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import seven_send.client as cl
|
|
|
|
|
from seven_send import SevenClient, SevenError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeResp:
|
|
|
|
|
def __init__(self, status_code=200, payload=None, text=""):
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
self._payload = payload
|
|
|
|
|
self.text = text
|
|
|
|
|
|
|
|
|
|
def json(self):
|
|
|
|
|
if self._payload is None:
|
|
|
|
|
raise ValueError("kein JSON")
|
|
|
|
|
return self._payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _patch_post(monkeypatch, capture, resp):
|
|
|
|
|
def fake_post(url, data=None, headers=None, timeout=None):
|
|
|
|
|
capture.append({"url": url, "data": data, "headers": headers})
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(cl.httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_sms_ok(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(
|
|
|
|
|
payload={"success": "100", "messages": [{"success": True}], "total_price": 0.075}))
|
|
|
|
|
r = SevenClient("key", sms_from="Notruf").send_sms("+4915112345678", "Hallo")
|
|
|
|
|
assert r.ok and bool(r) is True
|
|
|
|
|
assert r.channel == "sms" and r.price == 0.075
|
|
|
|
|
assert cap[0]["url"].endswith("/sms")
|
|
|
|
|
assert cap[0]["headers"]["X-Api-Key"] == "key"
|
|
|
|
|
assert cap[0]["data"] == {"to": "+4915112345678", "text": "Hallo", "from": "Notruf"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_voice_wraps_ssml(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload={"success": "100"}))
|
|
|
|
|
r = SevenClient("key", voice_name="de-de-female").send_voice("+49", "Test & <x>")
|
|
|
|
|
assert r.ok
|
|
|
|
|
txt = cap[0]["data"]["text"]
|
|
|
|
|
assert txt.startswith('<speak><voice name="de-de-female">')
|
|
|
|
|
assert "&" in txt and "<x>" in txt
|
|
|
|
|
assert cap[0]["url"].endswith("/voice")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_voice_no_ssml(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload={"success": "100"}))
|
|
|
|
|
SevenClient("key").send_voice("+49", "roh", ssml=False)
|
|
|
|
|
assert cap[0]["data"]["text"] == "roh"
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 13:00:02 +02:00
|
|
|
def test_send_sms_default_omits_sender(monkeypatch):
|
|
|
|
|
# Ohne sms_from kein "from" -> seven.io nutzt den Kontostandard-Absender
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload={"success": "100"}))
|
|
|
|
|
SevenClient("key").send_sms("+49", "Hi")
|
|
|
|
|
assert "from" not in cap[0]["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_sms_sender_override(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload={"success": "100"}))
|
|
|
|
|
SevenClient("key", sms_from="Firma").send_sms("+49", "Hi", sender="Aktion")
|
|
|
|
|
assert cap[0]["data"]["from"] == "Aktion" # per-call schlägt Instanz-Default
|
|
|
|
|
|
|
|
|
|
|
feat: seven-send 0.1.0 — Library + CLI für SMS und TTS-Anrufe via seven.io
Wiederverwendbarer Baustein (eigenes Paket), nutzbar in beliebigen Programmen,
Cron-Jobs und Alerts:
- client.py: synchroner SevenClient.send_sms / send_voice (Voice als SSML,
XML-escaped); Result mit ok/code/price/error, best-effort Fehlerbehandlung,
dry_run-Modus
- cli.py: `seven-send sms|voice` mit --dry-run, --json, sauberen Exit-Codes;
API-Key aus --api-key oder $SEVEN_API_KEY
- src-Layout, pyproject (console_script seven-send), MIT-Lizenz
- 12 Tests (Client + CLI), httpx gemockt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:33:12 +02:00
|
|
|
def test_failure_code(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload={"success": "401"}))
|
|
|
|
|
r = SevenClient("key").send_sms("+49", "x")
|
|
|
|
|
assert not r.ok and r.code == "401"
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 12:51:11 +02:00
|
|
|
def test_sms_bare_int_code_ok(monkeypatch):
|
|
|
|
|
# SMS-Endpunkt liefert oft nur den nackten Code -> resp.json() == int 100
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload=100, text="100"))
|
|
|
|
|
r = SevenClient("key").send_sms("+49", "x")
|
|
|
|
|
assert r.ok and r.code == "100"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sms_bare_int_code_error(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(payload=900, text="900"))
|
|
|
|
|
r = SevenClient("key").send_sms("+49", "x")
|
|
|
|
|
assert not r.ok and r.code == "900" and "900" in r.error
|
|
|
|
|
|
|
|
|
|
|
feat: seven-send 0.1.0 — Library + CLI für SMS und TTS-Anrufe via seven.io
Wiederverwendbarer Baustein (eigenes Paket), nutzbar in beliebigen Programmen,
Cron-Jobs und Alerts:
- client.py: synchroner SevenClient.send_sms / send_voice (Voice als SSML,
XML-escaped); Result mit ok/code/price/error, best-effort Fehlerbehandlung,
dry_run-Modus
- cli.py: `seven-send sms|voice` mit --dry-run, --json, sauberen Exit-Codes;
API-Key aus --api-key oder $SEVEN_API_KEY
- src-Layout, pyproject (console_script seven-send), MIT-Lizenz
- 12 Tests (Client + CLI), httpx gemockt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:33:12 +02:00
|
|
|
def test_http_error(monkeypatch):
|
|
|
|
|
cap = []
|
|
|
|
|
_patch_post(monkeypatch, cap, FakeResp(status_code=500, text="boom"))
|
|
|
|
|
r = SevenClient("key").send_sms("+49", "x")
|
|
|
|
|
assert not r.ok and "HTTP 500" in r.error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_network_exception(monkeypatch):
|
|
|
|
|
def boom(*a, **k):
|
|
|
|
|
raise RuntimeError("offline")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(cl.httpx, "post", boom)
|
|
|
|
|
r = SevenClient("key").send_sms("+49", "x")
|
|
|
|
|
assert not r.ok and "offline" in r.error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dry_run_does_not_post(monkeypatch):
|
|
|
|
|
def boom(*a, **k):
|
|
|
|
|
raise AssertionError("kein POST im dry-run")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(cl.httpx, "post", boom)
|
|
|
|
|
r = SevenClient("", dry_run=True).send_sms("+49", "x")
|
|
|
|
|
assert r.ok and r.code == "dry-run"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_key_raises():
|
|
|
|
|
with pytest.raises(SevenError):
|
|
|
|
|
SevenClient("")
|