feat: publish dbussmsforward refactor
This commit is contained in:
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"phone_number": "10690001",
|
||||
"content": "【运营商】您的验证码 123456,5分钟内有效",
|
||||
"received_at": "2026-04-24 11:20:00"
|
||||
},
|
||||
{
|
||||
"phone_number": "10010",
|
||||
"content": "余额提醒:当前余额 32.10 元",
|
||||
"received_at": "2026-04-24 11:21:00"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,601 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def test_validate_config_command_rejects_enabled_autman_push_without_required_fields(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "",
|
||||
"access_token": "",
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("notifications.autman_push.api_url is required", result.stderr)
|
||||
|
||||
def test_validate_config_command_rejects_invalid_autman_push_method(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/push",
|
||||
"access_token": "***",
|
||||
"method": "PUT",
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("notifications.autman_push.method must be GET or POST", result.stderr)
|
||||
|
||||
def test_validate_config_command_rejects_invalid_modemmanager_ready_values(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "",
|
||||
"state_ready_values": [],
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("sms_source.state_ready_values must not be empty", result.stderr)
|
||||
self.assertIn("sms_source.modem_object_path is required", result.stderr)
|
||||
|
||||
def test_validate_config_command_rejects_negative_poll_interval(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "/tmp/messages.json",
|
||||
"poll_interval_seconds": -1,
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("sms_source.poll_interval_seconds must be >= 0", result.stderr)
|
||||
|
||||
def test_run_command_processes_file_source_messages(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "1068",
|
||||
"content": "您的验证码是 654321",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-b"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("短信转发 1068", result.stdout)
|
||||
self.assertIn("router-b", result.stdout)
|
||||
self.assertIn("654321", result.stdout)
|
||||
|
||||
def test_send_command_outputs_manual_message(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-c"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"send",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--to",
|
||||
"10086",
|
||||
"--text",
|
||||
"manual body",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("manual body", result.stdout)
|
||||
self.assertIn("10086", result.stdout)
|
||||
|
||||
def test_send_command_real_sms_requires_modemmanager_source(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"send",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--to",
|
||||
"10086",
|
||||
"--text",
|
||||
"manual body",
|
||||
"--real-sms",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("sms send is not configured", result.stderr)
|
||||
|
||||
def test_run_command_supports_multi_poll_deduplication(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "1068",
|
||||
"content": "首条短信",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-loop"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": str(messages_path),
|
||||
"poll_interval_seconds": 0,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--iterations",
|
||||
"2",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(result.stdout.count("短信转发 1068"), 1)
|
||||
self.assertIn("polls=2", result.stderr)
|
||||
self.assertIn("duplicates_skipped=1", result.stderr)
|
||||
|
||||
def test_send_command_dispatches_autman_push_with_mock_server(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
capture_path = root / "capture.json"
|
||||
mock_server = root / "mock_autman_server.py"
|
||||
mock_server.write_text(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
capture_path = Path(sys.argv[1])
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
payload = self.rfile.read(length).decode('utf-8')
|
||||
capture_path.write_text(json.dumps({
|
||||
'path': self.path,
|
||||
'headers': dict(self.headers),
|
||||
'body': json.loads(payload),
|
||||
}, ensure_ascii=False), encoding='utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok": true}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||
print(server.server_address[1], flush=True)
|
||||
server.handle_request()
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with subprocess.Popen(
|
||||
[sys.executable, str(mock_server), str(capture_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=str(root),
|
||||
) as server:
|
||||
result = None
|
||||
port_line = server.stdout.readline().strip()
|
||||
if not port_line:
|
||||
_, stderr_text = server.communicate(timeout=5)
|
||||
self.fail(stderr_text)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-d"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": f"http://127.0.0.1:{port_line}/api/push",
|
||||
"access_token": "cli-token",
|
||||
"method": "POST",
|
||||
}
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"send",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--to",
|
||||
"10010",
|
||||
"--text",
|
||||
"autman cli body",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if server.poll() is None:
|
||||
server.communicate(timeout=5)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
captured = json.loads(capture_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(captured["path"], "/api/push")
|
||||
self.assertEqual(captured["body"]["title"], "短信转发 10010")
|
||||
self.assertEqual(captured["body"]["from"], "router-d")
|
||||
self.assertIn("autman cli body", captured["body"]["content"])
|
||||
self.assertEqual(captured["headers"]["Authorization"], "Bearer cli-token")
|
||||
|
||||
def test_run_command_reports_sender_level_failures_and_keeps_partial_success_output(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "1068",
|
||||
"content": "sender partial failure",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
server_script = root / "failing_server.py"
|
||||
server_script.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
self.send_response(500)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"error": "boom"}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||
print(server.server_address[1], flush=True)
|
||||
server.handle_request()
|
||||
"""
|
||||
).strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with subprocess.Popen(
|
||||
[sys.executable, str(server_script)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=str(root),
|
||||
) as server:
|
||||
port_line = server.stdout.readline().strip()
|
||||
if not port_line:
|
||||
_, stderr_text = server.communicate(timeout=5)
|
||||
self.fail(stderr_text)
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-observe"},
|
||||
"notifications": {
|
||||
"console": {"enabled": True},
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": f"http://127.0.0.1:{port_line}/api/push",
|
||||
"access_token": "***",
|
||||
"method": "POST",
|
||||
},
|
||||
},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if server.poll() is None:
|
||||
server.communicate(timeout=5)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("[console] 短信转发 1068", result.stdout)
|
||||
self.assertIn("sender partial failure", result.stdout)
|
||||
self.assertIn("dispatched=1", result.stderr)
|
||||
self.assertIn("dispatch_failures=1", result.stderr)
|
||||
self.assertIn("dispatch_failure sender=autman_push", result.stderr)
|
||||
self.assertIn("error_category=http", result.stderr)
|
||||
self.assertIn("status=500", result.stderr)
|
||||
self.assertIn("response_excerpt={\"error\": \"boom\"}", result.stderr)
|
||||
|
||||
def test_run_command_reports_file_source_fetch_issues(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text('{"broken": ', encoding="utf-8")
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-fetch"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("fetch_issues=1", result.stderr)
|
||||
self.assertIn("fetch_issue kind=json_decode_error", result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class ServeWebCliTests(unittest.TestCase):
|
||||
def test_serve_web_command_starts_http_server_and_serves_health(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"daemon": {"host": "127.0.0.1", "port": 0},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"serve-web",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--port",
|
||||
"18091",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:18091/api/health", timeout=1) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(body["status"], "ok")
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
stderr = process.stderr.read()
|
||||
self.fail(f"serve-web did not become ready: {stderr}")
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if process.stdout:
|
||||
process.stdout.close()
|
||||
if process.stderr:
|
||||
process.stderr.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
import unittest
|
||||
|
||||
from dbussmsforward.config import AppConfig, load_config_dict
|
||||
|
||||
|
||||
class ConfigModelTests(unittest.TestCase):
|
||||
def test_load_config_dict_maps_autmanpush_and_defaults(self):
|
||||
raw = {
|
||||
"daemon": {"mode": "web"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://push.example/api",
|
||||
"access_token": "***",
|
||||
"method": "POST",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertIsInstance(config, AppConfig)
|
||||
self.assertEqual(config.daemon.mode, "web")
|
||||
self.assertTrue(config.forwarding.enable_sms_code_extraction)
|
||||
self.assertTrue(config.notifications.autman_push.enabled)
|
||||
self.assertEqual(config.notifications.autman_push.api_url, "https://push.example/api")
|
||||
self.assertEqual(config.notifications.autman_push.access_token, "secret-token")
|
||||
self.assertEqual(config.notifications.autman_push.method, "POST")
|
||||
|
||||
def test_load_config_dict_accepts_legacy_appsettings_wrapper(self):
|
||||
raw = {
|
||||
"appSettings": {
|
||||
"DeviceName": "router-a",
|
||||
"SmsCodeKey": "验证码±code",
|
||||
"AutmanPushConfig": {
|
||||
"ApiUrl": "https://legacy.example/push",
|
||||
"AccessToken": "legacy-token",
|
||||
"Method": "GET",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertEqual(config.forwarding.device_name, "router-a")
|
||||
self.assertEqual(config.forwarding.sms_code_key, "验证码±code")
|
||||
self.assertEqual(config.notifications.autman_push.api_url, "https://legacy.example/push")
|
||||
self.assertEqual(config.notifications.autman_push.access_token, "legacy-token")
|
||||
self.assertEqual(config.notifications.autman_push.method, "GET")
|
||||
|
||||
def test_load_config_dict_reads_sms_source_and_console_notification(self):
|
||||
raw = {
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "/tmp/messages.json",
|
||||
"delete_after_read": True,
|
||||
"poll_interval_seconds": 9,
|
||||
},
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertTrue(config.notifications.console.enabled)
|
||||
self.assertEqual(config.sms_source.kind, "file")
|
||||
self.assertEqual(config.sms_source.path, "/tmp/messages.json")
|
||||
self.assertTrue(config.sms_source.delete_after_read)
|
||||
self.assertEqual(config.sms_source.poll_interval_seconds, 9)
|
||||
|
||||
def test_load_config_dict_reads_modemmanager_sms_source_options(self):
|
||||
raw = {
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "auto",
|
||||
"system_bus_address": "unix:path=/run/dbus/system_bus_socket",
|
||||
"storage_blacklist": ["SM", "mt", "sm"],
|
||||
"state_ready_values": [3, "4"],
|
||||
}
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertEqual(config.sms_source.kind, "modemmanager")
|
||||
self.assertEqual(config.sms_source.modem_object_path, "auto")
|
||||
self.assertEqual(config.sms_source.system_bus_address, "unix:path=/run/dbus/system_bus_socket")
|
||||
self.assertEqual(config.sms_source.storage_blacklist, ["sm", "mt"])
|
||||
self.assertEqual(config.sms_source.state_ready_values, [3, 4])
|
||||
|
||||
def test_load_config_dict_maps_all_notification_channels(self):
|
||||
raw = {
|
||||
"notifications": {
|
||||
"email": {
|
||||
"enabled": True,
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 587,
|
||||
"enable_ssl": False,
|
||||
"email_key": "mail-key",
|
||||
"send_email": "sender@example.com",
|
||||
"receive_email": "receiver@example.com",
|
||||
},
|
||||
"pushplus": {
|
||||
"enabled": True,
|
||||
"token": "push-token",
|
||||
"endpoint": "https://pushplus.example/send",
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": True,
|
||||
"access_token": "ding-token",
|
||||
"secret": "ding-secret",
|
||||
},
|
||||
"bark": {
|
||||
"enabled": True,
|
||||
"server_url": "https://bark.example",
|
||||
"bark_key": "bark-key",
|
||||
},
|
||||
"wecom_application": {
|
||||
"enabled": True,
|
||||
"corp_id": "corp-id",
|
||||
"agent_id": "1000001",
|
||||
"application_secret": "app-secret",
|
||||
},
|
||||
"telegram_bot": {
|
||||
"enabled": True,
|
||||
"enable_custom_api": True,
|
||||
"custom_api": "https://tg.example",
|
||||
"bot_token": "bot-token",
|
||||
"chat_id": "10001",
|
||||
},
|
||||
"shell": {
|
||||
"enabled": True,
|
||||
"shell_path": "/tmp/hook.sh",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertTrue(config.notifications.email.enabled)
|
||||
self.assertEqual(config.notifications.email.smtp_host, "smtp.example.com")
|
||||
self.assertEqual(config.notifications.pushplus.endpoint, "https://pushplus.example/send")
|
||||
self.assertEqual(config.notifications.dingtalk.secret, "ding-secret")
|
||||
self.assertEqual(config.notifications.bark.bark_key, "bark-key")
|
||||
self.assertEqual(config.notifications.wecom_application.agent_id, "1000001")
|
||||
self.assertTrue(config.notifications.telegram_bot.enable_custom_api)
|
||||
self.assertEqual(config.notifications.telegram_bot.custom_api, "https://tg.example")
|
||||
self.assertEqual(config.notifications.shell.shell_path, "/tmp/hook.sh")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
BASE_ENV = {**os.environ, "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class DeployInstallerTests(unittest.TestCase):
|
||||
def test_env_check_command_supports_json_output(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text("[]", encoding="utf-8")
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-json"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"env-check",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=BASE_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["summary"]["failed"], 0)
|
||||
self.assertEqual(payload["summary"]["status"], "ok")
|
||||
self.assertTrue(any(item["name"] == "sms_source.path" for item in payload["results"]))
|
||||
|
||||
def test_debian_installer_runs_env_check_and_writes_json_report(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
fakebin = root / "fakebin"
|
||||
fakebin.mkdir()
|
||||
systemctl_log = root / "systemctl.log"
|
||||
(fakebin / "systemctl").write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"printf '%s\\n' \"$*\" >> {systemctl_log}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(fakebin / "systemctl").chmod(0o755)
|
||||
|
||||
config_dir = root / "etc" / "dbussmsforward"
|
||||
config_dir.mkdir(parents=True)
|
||||
messages_path = root / "var" / "dbussmsforward-messages.json"
|
||||
messages_path.parent.mkdir(parents=True)
|
||||
messages_path.write_text("[]", encoding="utf-8")
|
||||
(config_dir / "appsettings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "debian-installer-test"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", "deploy/debian/install.sh"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env={
|
||||
**BASE_ENV,
|
||||
"ROOT_DIR": str(root),
|
||||
"PATH": f"{fakebin}:{os.environ.get('PATH', '')}",
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
report_path = config_dir / "install-env-check.json"
|
||||
self.assertTrue(report_path.exists())
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["summary"]["failed"], 0)
|
||||
logged = systemctl_log.read_text(encoding="utf-8")
|
||||
self.assertIn("daemon-reload", logged)
|
||||
self.assertIn("enable dbussmsforward", logged)
|
||||
|
||||
def test_openwrt_installer_blocks_when_env_check_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_dir = root / "etc" / "dbussmsforward"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "appsettings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "",
|
||||
"access_token": "",
|
||||
}
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "auto",
|
||||
"system_bus_address": "unix:path=/tmp/definitely-missing-bus.sock",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", "deploy/openwrt/install.sh"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env={
|
||||
**BASE_ENV,
|
||||
"ROOT_DIR": str(root),
|
||||
"SKIP_SERVICE_ENABLE": "1",
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Environment check failed", result.stderr)
|
||||
report_path = config_dir / "install-env-check.json"
|
||||
self.assertTrue(report_path.exists())
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertGreater(payload["summary"]["failed"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**os.environ, "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class DeploymentArtifactsTests(unittest.TestCase):
|
||||
def test_debian_systemd_unit_uses_forever_polling_and_pythonpath(self):
|
||||
service_path = PROJECT_ROOT / "deploy" / "debian" / "dbussmsforward.service"
|
||||
content = service_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("WorkingDirectory=/opt/dbussmsforward", content)
|
||||
self.assertIn("Environment=PYTHONPATH=/opt/dbussmsforward/src", content)
|
||||
self.assertIn("--iterations 0", content)
|
||||
self.assertIn("/etc/dbussmsforward/appsettings.json", content)
|
||||
|
||||
def test_openwrt_init_script_uses_procd_and_forever_polling(self):
|
||||
init_path = PROJECT_ROOT / "deploy" / "openwrt" / "dbussmsforward.init"
|
||||
content = init_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("USE_PROCD=1", content)
|
||||
self.assertIn("PYTHONPATH=\"$APP_DIR/src\"", content)
|
||||
self.assertIn("--iterations 0", content)
|
||||
self.assertIn('CONFIG_FILE="/etc/dbussmsforward/appsettings.json"', content)
|
||||
|
||||
def test_deploy_config_validates(self):
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
"deploy/common/appsettings.deploy.json",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("config ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class EnvironmentCheckCliTests(unittest.TestCase):
|
||||
def test_env_check_command_reports_missing_runtime_dependencies(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/push",
|
||||
"access_token": "secret-token",
|
||||
}
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"system_bus_address": "unix:path=/tmp/does-not-exist-system-bus",
|
||||
"modem_object_path": "auto",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"env-check",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("status=fail", result.stdout)
|
||||
self.assertIn("category=python_module name=dbus_next", result.stdout)
|
||||
self.assertIn("category=file name=sms_source.system_bus_address", result.stdout)
|
||||
self.assertIn("category=config name=notifications.autman_push.api_url", result.stdout)
|
||||
self.assertIn("summary failed=", result.stdout)
|
||||
|
||||
def test_env_check_command_passes_for_file_source_console_only_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text("[]", encoding="utf-8")
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-self-check"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": str(messages_path),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"env-check",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("status=ok category=config name=config_path", result.stdout)
|
||||
self.assertIn("status=ok category=file name=sms_source.path", result.stdout)
|
||||
self.assertIn("summary failed=0", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
def test_editable_install_exposes_dbussmsforward_command(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
venv_dir = Path(tmpdir) / "venv"
|
||||
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
|
||||
python_bin = venv_dir / "bin" / "python"
|
||||
pip_bin = venv_dir / "bin" / "pip"
|
||||
|
||||
subprocess.run([str(python_bin), "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
subprocess.run([str(pip_bin), "install", "-e", str(PROJECT_ROOT)], check=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[str(venv_dir / "bin" / "dbussmsforward"), "validate-config", "--config", "appsettings.python.json"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("config ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,683 @@
|
||||
import json
|
||||
import unittest
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.request import Request
|
||||
|
||||
from dbussmsforward.app import build_runtime, run_forward_loop
|
||||
from dbussmsforward.config import load_config_dict
|
||||
from dbussmsforward.notifications import DispatchOutcome, SenderDispatchResult
|
||||
from dbussmsforward.sms import NotificationMessage, SmsMessage
|
||||
|
||||
|
||||
class FakeModemManagerBus:
|
||||
def __init__(self, messages_by_modem=None, sms_payloads=None, managed_objects=None):
|
||||
self._messages_by_modem = messages_by_modem or {}
|
||||
self._sms_payloads = sms_payloads or {}
|
||||
self._managed_objects = managed_objects or list(self._messages_by_modem.keys())
|
||||
self.sent_messages = []
|
||||
|
||||
def get_managed_objects(self):
|
||||
return list(self._managed_objects)
|
||||
|
||||
def get_messages(self, modem_path: str):
|
||||
return list(self._messages_by_modem.get(modem_path, []))
|
||||
|
||||
def get_sms(self, sms_path: str):
|
||||
return dict(self._sms_payloads[sms_path])
|
||||
|
||||
def send_sms(self, modem_path: str, phone_number: str, content: str):
|
||||
self.sent_messages.append(
|
||||
{
|
||||
"modem_path": modem_path,
|
||||
"phone_number": phone_number,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"modem_path": modem_path,
|
||||
"phone_number": phone_number,
|
||||
"content": content,
|
||||
"message_path": f"{modem_path}/sent/{len(self.sent_messages)}",
|
||||
}
|
||||
|
||||
|
||||
class SequenceSmsSource:
|
||||
def __init__(self, batches):
|
||||
self._batches = [[SmsMessage(**item) for item in batch] for batch in batches]
|
||||
self.calls = 0
|
||||
|
||||
def fetch(self):
|
||||
index = self.calls
|
||||
self.calls += 1
|
||||
if index >= len(self._batches):
|
||||
return []
|
||||
return list(self._batches[index])
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.dispatched = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.dispatched.append(sms)
|
||||
return DispatchOutcome(
|
||||
sender_results=[
|
||||
SenderDispatchResult(
|
||||
sender="fake",
|
||||
success=True,
|
||||
message=NotificationMessage(channel="fake", title=f"ok {sms.phone_number}", body=sms.content),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FlakyDispatcher:
|
||||
def __init__(self, failing_numbers=None):
|
||||
self.failing_numbers = set(failing_numbers or [])
|
||||
self.calls = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.calls.append(sms.phone_number)
|
||||
if sms.phone_number in self.failing_numbers:
|
||||
raise RuntimeError(f"dispatch failed for {sms.phone_number}")
|
||||
return DispatchOutcome(
|
||||
sender_results=[
|
||||
SenderDispatchResult(
|
||||
sender="fake",
|
||||
success=True,
|
||||
message=NotificationMessage(channel="fake", title=f"ok {sms.phone_number}", body=sms.content),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FakeSender:
|
||||
def __init__(self, name: str, *, fail_for_numbers=None):
|
||||
self.name = name
|
||||
self.fail_for_numbers = set(fail_for_numbers or [])
|
||||
self.calls = []
|
||||
|
||||
def send(self, sms: SmsMessage, device_name: str):
|
||||
self.calls.append((sms.phone_number, device_name))
|
||||
if sms.phone_number in self.fail_for_numbers:
|
||||
raise RuntimeError(f"{self.name} failed for {sms.phone_number}")
|
||||
return NotificationMessage(channel=self.name, title=f"{self.name}:{sms.phone_number}", body=sms.content)
|
||||
|
||||
|
||||
class RuntimeFlowTests(unittest.TestCase):
|
||||
def test_file_source_skips_invalid_records_and_reports_fetch_issues(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
messages_path = Path(tmpdir) / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "10086",
|
||||
"content": "valid message",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
},
|
||||
{
|
||||
"phone_number": "",
|
||||
"content": "",
|
||||
"received_at": "2026-04-24 12:01:00",
|
||||
},
|
||||
"not-a-dict",
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.source = __import__("dbussmsforward.source", fromlist=["FileSmsSource"]).FileSmsSource(
|
||||
path=str(messages_path)
|
||||
)
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=1)
|
||||
|
||||
self.assertEqual(result.fetched, 1)
|
||||
self.assertEqual(result.dispatched, 1)
|
||||
self.assertEqual(result.fetch_issue_count, 2)
|
||||
self.assertEqual(len(result.fetch_issue_details), 2)
|
||||
self.assertEqual(result.fetch_issue_details[0].kind, "invalid_record")
|
||||
self.assertIn("index=1", result.fetch_issue_details[0].detail)
|
||||
self.assertEqual(result.fetch_issue_details[1].kind, "invalid_record")
|
||||
self.assertIn("index=2", result.fetch_issue_details[1].detail)
|
||||
|
||||
def test_file_source_reports_json_decode_errors_without_crashing_poll_loop(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
messages_path = Path(tmpdir) / "messages.json"
|
||||
messages_path.write_text('{"broken": ', encoding="utf-8")
|
||||
runtime.source = __import__("dbussmsforward.source", fromlist=["FileSmsSource"]).FileSmsSource(
|
||||
path=str(messages_path)
|
||||
)
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=1)
|
||||
|
||||
self.assertEqual(result.polls, 1)
|
||||
self.assertEqual(result.fetched, 0)
|
||||
self.assertEqual(result.dispatched, 0)
|
||||
self.assertEqual(result.fetch_issue_count, 1)
|
||||
self.assertEqual(result.fetch_issue_details[0].kind, "json_decode_error")
|
||||
self.assertIn("Expecting value", result.fetch_issue_details[0].detail)
|
||||
|
||||
def test_runtime_dispatches_sms_from_file_source_to_console_sender(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "tests/fixtures/messages.json",
|
||||
"delete_after_read": False,
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
messages = list(runtime.source.fetch())
|
||||
self.assertEqual(len(messages), 2)
|
||||
|
||||
delivered = [runtime.dispatcher.dispatch(message) for message in messages]
|
||||
|
||||
self.assertEqual(len(delivered), 2)
|
||||
self.assertIn("验证码 123456", delivered[0][0].body)
|
||||
self.assertIn("router-a", delivered[0][0].body)
|
||||
self.assertEqual(delivered[0][0].channel, "console")
|
||||
|
||||
def test_runtime_send_command_dispatches_manual_message(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
result = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10086", content="manual send", received_at="2026-04-24 11:30:00")
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertIn("manual send", result[0].body)
|
||||
self.assertIn("10086", result[0].title)
|
||||
|
||||
def test_runtime_dispatches_sms_to_autman_push_post_sender(self):
|
||||
requests: list[Request] = []
|
||||
|
||||
def opener(request: Request):
|
||||
requests.append(request)
|
||||
return {"ok": True}
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
"method": "POST",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
result = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].channel, "autman_push")
|
||||
self.assertEqual(result[0].title, "短信转发 10010")
|
||||
self.assertEqual(len(requests), 1)
|
||||
self.assertEqual(requests[0].full_url, "https://autman.example/api/push")
|
||||
self.assertEqual(requests[0].get_method(), "POST")
|
||||
self.assertEqual(requests[0].headers["Authorization"], "Bearer secret-token")
|
||||
payload = json.loads(requests[0].data.decode("utf-8"))
|
||||
self.assertEqual(payload["title"], "短信转发 10010")
|
||||
self.assertIn("autman body", payload["content"])
|
||||
self.assertEqual(payload["from"], "router-a")
|
||||
|
||||
def test_runtime_records_autman_push_errors_in_dispatch_outcome(self):
|
||||
def opener(request: Request):
|
||||
raise HTTPError(request.full_url, 500, "boom", hdrs=None, fp=None)
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
outcome = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.success_count, 0)
|
||||
self.assertEqual(len(outcome.failures), 1)
|
||||
self.assertEqual(outcome.failures[0].sender, "autman_push")
|
||||
self.assertEqual(outcome.failures[0].error_category, "http")
|
||||
self.assertEqual(outcome.failures[0].status_code, 500)
|
||||
self.assertEqual(outcome.failures[0].reason, "boom")
|
||||
self.assertIn("status=500", outcome.failures[0].error)
|
||||
|
||||
def test_runtime_classifies_autman_push_timeout_errors(self):
|
||||
def opener(request: Request):
|
||||
raise TimeoutError("push timed out")
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
outcome = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.failures[0].error_category, "timeout")
|
||||
self.assertIsNone(outcome.failures[0].status_code)
|
||||
self.assertIn("push timed out", outcome.failures[0].error)
|
||||
|
||||
def test_runtime_captures_autman_push_http_response_snippet(self):
|
||||
class FakeHttpResponse:
|
||||
def __init__(self, payload: str):
|
||||
self._payload = payload.encode("utf-8")
|
||||
|
||||
def read(self, *_args, **_kwargs):
|
||||
return self._payload
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def opener(request: Request):
|
||||
raise HTTPError(
|
||||
request.full_url,
|
||||
502,
|
||||
"Bad Gateway",
|
||||
hdrs=None,
|
||||
fp=FakeHttpResponse('{"error":"upstream exploded"}'),
|
||||
)
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
outcome = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.failures[0].error_category, "http")
|
||||
self.assertEqual(outcome.failures[0].status_code, 502)
|
||||
self.assertIn("upstream exploded", outcome.failures[0].response_excerpt)
|
||||
|
||||
def test_dispatcher_returns_per_sender_results_and_preserves_partial_success(self):
|
||||
from dbussmsforward.notifications import NotificationDispatcher
|
||||
|
||||
console = FakeSender("console")
|
||||
autman = FakeSender("autman_push", fail_for_numbers={"10010"})
|
||||
dispatcher = NotificationDispatcher(senders=[console, autman], device_name="router-a")
|
||||
|
||||
outcome = dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual([message.channel for message in outcome.messages], ["console"])
|
||||
self.assertEqual(len(outcome.sender_results), 2)
|
||||
self.assertEqual(outcome.sender_results[0].sender, "console")
|
||||
self.assertTrue(outcome.sender_results[0].success)
|
||||
self.assertEqual(outcome.sender_results[1].sender, "autman_push")
|
||||
self.assertFalse(outcome.sender_results[1].success)
|
||||
self.assertIn("autman_push failed for 10010", outcome.sender_results[1].error)
|
||||
self.assertEqual(console.calls, [("10010", "router-a")])
|
||||
self.assertEqual(autman.calls, [("10010", "router-a")])
|
||||
|
||||
def test_runtime_fetches_ready_messages_from_modemmanager_source(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-mm"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "/org/freedesktop/ModemManager1/Modem/0",
|
||||
"storage_blacklist": ["sm"],
|
||||
"state_ready_values": [3],
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
|
||||
def factory(_config):
|
||||
return FakeModemManagerBus(
|
||||
messages_by_modem={
|
||||
"/org/freedesktop/ModemManager1/Modem/0": [
|
||||
"/org/freedesktop/ModemManager1/SMS/1",
|
||||
"/org/freedesktop/ModemManager1/SMS/2",
|
||||
]
|
||||
},
|
||||
sms_payloads={
|
||||
"/org/freedesktop/ModemManager1/SMS/1": {
|
||||
"state": 3,
|
||||
"number": "+8613800138000",
|
||||
"text": "真实短信验证码 998877",
|
||||
"timestamp": "2026-04-24T12:34:56+08:00",
|
||||
"storage": "me",
|
||||
},
|
||||
"/org/freedesktop/ModemManager1/SMS/2": {
|
||||
"state": 2,
|
||||
"number": "+8613800138001",
|
||||
"text": "未完成短信",
|
||||
"timestamp": "2026-04-24T12:35:00+08:00",
|
||||
"storage": "me",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
runtime = build_runtime(config, bus_factory=factory)
|
||||
messages = list(runtime.source.fetch())
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0].phone_number, "+8613800138000")
|
||||
self.assertIn("998877", messages[0].content)
|
||||
self.assertEqual(messages[0].received_at, "2026-04-24 12:34:56")
|
||||
|
||||
delivered = runtime.dispatcher.dispatch(messages[0])
|
||||
self.assertEqual(delivered[0].channel, "console")
|
||||
self.assertIn("router-mm", delivered[0].body)
|
||||
|
||||
def test_runtime_auto_discovers_modem_paths_for_modemmanager_source(self):
|
||||
raw = {
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "auto",
|
||||
"storage_blacklist": [],
|
||||
"state_ready_values": [3],
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
|
||||
def factory(_config):
|
||||
return FakeModemManagerBus(
|
||||
messages_by_modem={
|
||||
"/org/freedesktop/ModemManager1/Modem/0": ["/org/freedesktop/ModemManager1/SMS/10"],
|
||||
"/org/freedesktop/ModemManager1/Modem/1": ["/org/freedesktop/ModemManager1/SMS/11"],
|
||||
},
|
||||
sms_payloads={
|
||||
"/org/freedesktop/ModemManager1/SMS/10": {
|
||||
"state": 3,
|
||||
"number": "10000",
|
||||
"text": "modem0 ready",
|
||||
"timestamp": "2026-04-24T10:00:00+00:00",
|
||||
"storage": "me",
|
||||
},
|
||||
"/org/freedesktop/ModemManager1/SMS/11": {
|
||||
"state": 3,
|
||||
"number": "10001",
|
||||
"text": "modem1 ready",
|
||||
"timestamp": "2026-04-24T10:05:00+00:00",
|
||||
"storage": "mt",
|
||||
},
|
||||
},
|
||||
managed_objects=[
|
||||
"/org/freedesktop/ModemManager1/Modem/0",
|
||||
"/org/freedesktop/ModemManager1/Modem/1",
|
||||
],
|
||||
)
|
||||
|
||||
runtime = build_runtime(config, bus_factory=factory)
|
||||
messages = list(runtime.source.fetch())
|
||||
|
||||
self.assertEqual(len(messages), 2)
|
||||
self.assertEqual({message.phone_number for message in messages}, {"10000", "10001"})
|
||||
|
||||
def test_runtime_send_sms_uses_modemmanager_sender_and_returns_delivery_record(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-send"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "/org/freedesktop/ModemManager1/Modem/9",
|
||||
"state_ready_values": [3],
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
bus = FakeModemManagerBus(messages_by_modem={"/org/freedesktop/ModemManager1/Modem/9": []})
|
||||
runtime = build_runtime(config, bus_factory=lambda _config: bus)
|
||||
|
||||
result = runtime.sms_sender.send(phone_number="10086", content="真实发送链路")
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(result.phone_number, "10086")
|
||||
self.assertEqual(result.content, "真实发送链路")
|
||||
self.assertEqual(result.modem_path, "/org/freedesktop/ModemManager1/Modem/9")
|
||||
self.assertEqual(result.message_path, "/org/freedesktop/ModemManager1/Modem/9/sent/1")
|
||||
self.assertEqual(
|
||||
bus.sent_messages,
|
||||
[
|
||||
{
|
||||
"modem_path": "/org/freedesktop/ModemManager1/Modem/9",
|
||||
"phone_number": "10086",
|
||||
"content": "真实发送链路",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_runtime_send_sms_without_sender_returns_disabled_result(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
|
||||
result = runtime.sms_sender.send(phone_number="10086", content="noop")
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.error_category, "disabled")
|
||||
self.assertIn("sms send is not configured", result.error)
|
||||
|
||||
def test_forward_loop_deduplicates_messages_across_polls(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[
|
||||
{
|
||||
"phone_number": "10086",
|
||||
"content": "验证码 123456",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"phone_number": "10086",
|
||||
"content": "验证码 123456",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
},
|
||||
{
|
||||
"phone_number": "10010",
|
||||
"content": "余额提醒",
|
||||
"received_at": "2026-04-24 12:01:00",
|
||||
},
|
||||
],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = RecordingDispatcher()
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=2)
|
||||
|
||||
self.assertEqual(result.polls, 2)
|
||||
self.assertEqual(result.fetched, 3)
|
||||
self.assertEqual(result.dispatched, 2)
|
||||
self.assertEqual(result.duplicates_skipped, 1)
|
||||
self.assertEqual(
|
||||
[(sms.phone_number, sms.content) for sms in runtime.dispatcher.dispatched],
|
||||
[("10086", "验证码 123456"), ("10010", "余额提醒")],
|
||||
)
|
||||
|
||||
def test_forward_loop_respects_max_history_limit(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[{"phone_number": "1", "content": "A", "received_at": "t1"}],
|
||||
[{"phone_number": "2", "content": "B", "received_at": "t2"}],
|
||||
[{"phone_number": "3", "content": "C", "received_at": "t3"}],
|
||||
[{"phone_number": "1", "content": "A", "received_at": "t1"}],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = RecordingDispatcher()
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=4, max_seen_messages=2)
|
||||
|
||||
self.assertEqual(result.dispatched, 4)
|
||||
self.assertEqual(result.duplicates_skipped, 0)
|
||||
self.assertEqual(
|
||||
[sms.phone_number for sms in runtime.dispatcher.dispatched],
|
||||
["1", "2", "3", "1"],
|
||||
)
|
||||
|
||||
def test_forward_loop_continues_after_dispatch_errors_and_retries_failed_messages(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[
|
||||
{"phone_number": "500", "content": "first try", "received_at": "t1"},
|
||||
{"phone_number": "200", "content": "delivered", "received_at": "t2"},
|
||||
],
|
||||
[
|
||||
{"phone_number": "500", "content": "first try", "received_at": "t1"},
|
||||
],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = FlakyDispatcher(failing_numbers={"500"})
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=2)
|
||||
|
||||
self.assertEqual(result.polls, 2)
|
||||
self.assertEqual(result.fetched, 3)
|
||||
self.assertEqual(result.dispatched, 1)
|
||||
self.assertEqual(result.dispatch_failures, 2)
|
||||
self.assertEqual(result.duplicates_skipped, 0)
|
||||
self.assertEqual(runtime.dispatcher.calls, ["500", "200", "500"])
|
||||
self.assertEqual([message.title for message in result.delivered_messages], ["ok 200"])
|
||||
|
||||
def test_forward_loop_records_sender_failure_details_without_retrying_partial_success_message(self):
|
||||
from dbussmsforward.notifications import NotificationDispatcher
|
||||
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[
|
||||
{"phone_number": "10010", "content": "partial failure", "received_at": "t1"},
|
||||
],
|
||||
[
|
||||
{"phone_number": "10010", "content": "partial failure", "received_at": "t1"},
|
||||
],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = NotificationDispatcher(
|
||||
senders=[FakeSender("console"), FakeSender("autman_push", fail_for_numbers={"10010"})],
|
||||
device_name="router-a",
|
||||
)
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=2)
|
||||
|
||||
self.assertEqual(result.dispatched, 1)
|
||||
self.assertEqual(result.dispatch_failures, 1)
|
||||
self.assertEqual(result.duplicates_skipped, 1)
|
||||
self.assertEqual([message.channel for message in result.delivered_messages], ["console"])
|
||||
self.assertEqual(len(result.failure_details), 1)
|
||||
self.assertEqual(result.failure_details[0].sender, "autman_push")
|
||||
self.assertEqual(result.failure_details[0].phone_number, "10010")
|
||||
self.assertIn("autman_push failed for 10010", result.failure_details[0].error)
|
||||
|
||||
def test_forward_loop_records_fetch_issues_and_structured_sender_failures(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"console": {"enabled": True},
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
class FakeHttpResponse:
|
||||
def read(self, *_args, **_kwargs):
|
||||
return b'{"error":"boom body"}'
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def opener(request: Request):
|
||||
raise HTTPError(request.full_url, 503, "Service Unavailable", hdrs=None, fp=FakeHttpResponse())
|
||||
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
messages_path = Path(tmpdir) / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"phone_number": "10010", "content": "ok", "received_at": "t1"},
|
||||
{"phone_number": "", "content": "", "received_at": "t2"},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime.source = __import__("dbussmsforward.source", fromlist=["FileSmsSource"]).FileSmsSource(
|
||||
path=str(messages_path)
|
||||
)
|
||||
result = run_forward_loop(runtime, max_iterations=1)
|
||||
|
||||
self.assertEqual(result.fetch_issue_count, 1)
|
||||
self.assertEqual(result.dispatch_failures, 1)
|
||||
self.assertEqual(result.failure_details[0].sender, "autman_push")
|
||||
self.assertEqual(result.failure_details[0].error_category, "http")
|
||||
self.assertEqual(result.failure_details[0].status_code, 503)
|
||||
self.assertIn("boom body", result.failure_details[0].response_excerpt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dbussmsforward.config import load_config_dict
|
||||
from dbussmsforward.sms import SmsMessage, SmsSendResult
|
||||
from dbussmsforward.web import build_handler_class, create_web_app
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.messages.append(sms)
|
||||
return []
|
||||
|
||||
|
||||
class RecordingSmsSender:
|
||||
def __init__(self, *, result: SmsSendResult | None = None):
|
||||
self.calls = []
|
||||
self._result = result
|
||||
|
||||
def send(self, phone_number: str, content: str):
|
||||
self.calls.append({"phone_number": phone_number, "content": content})
|
||||
if self._result is not None:
|
||||
return SmsSendResult(
|
||||
success=self._result.success,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
modem_path=self._result.modem_path,
|
||||
message_path=self._result.message_path,
|
||||
error=self._result.error,
|
||||
error_category=self._result.error_category,
|
||||
)
|
||||
return SmsSendResult(
|
||||
success=True,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
modem_path="/org/freedesktop/ModemManager1/Modem/0",
|
||||
message_path="/org/freedesktop/ModemManager1/SMS/100",
|
||||
)
|
||||
|
||||
|
||||
class WebAppTests(unittest.TestCase):
|
||||
def test_health_status_and_settings_round_trip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = Path(tmpdir) / "appsettings.json"
|
||||
app = create_web_app(
|
||||
load_config_dict(
|
||||
{
|
||||
"daemon": {"host": "127.0.0.1", "port": 9090},
|
||||
"forwarding": {"device_name": "router-web"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
),
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
health = app.handle_request("GET", "/api/health")
|
||||
self.assertEqual(health["status"], 200)
|
||||
self.assertEqual(json.loads(health["body"])["status"], "ok")
|
||||
|
||||
system_status = app.handle_request("GET", "/api/system/status")
|
||||
status_payload = json.loads(system_status["body"])
|
||||
self.assertEqual(system_status["status"], 200)
|
||||
self.assertEqual(status_payload["daemon"]["host"], "127.0.0.1")
|
||||
self.assertEqual(status_payload["forwarding"]["device_name"], "router-web")
|
||||
self.assertEqual(status_payload["notifications"]["enabled_channels"], ["console"])
|
||||
|
||||
settings = app.handle_request("GET", "/api/settings")
|
||||
self.assertEqual(settings["status"], 200)
|
||||
settings_payload = json.loads(settings["body"])
|
||||
self.assertEqual(settings_payload["forwarding"]["device_name"], "router-web")
|
||||
|
||||
update = app.handle_request(
|
||||
"POST",
|
||||
"/api/settings",
|
||||
body=json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-updated"},
|
||||
"notifications": {"console": {"enabled": False}},
|
||||
}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(update["status"], 200)
|
||||
updated_payload = json.loads(update["body"])
|
||||
self.assertEqual(updated_payload["forwarding"]["device_name"], "router-updated")
|
||||
self.assertFalse(updated_payload["notifications"]["console"]["enabled"])
|
||||
|
||||
persisted = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["forwarding"]["device_name"], "router-updated")
|
||||
|
||||
def test_send_api_dispatches_message(self):
|
||||
app = create_web_app(load_config_dict({"forwarding": {"device_name": "router-web"}}))
|
||||
dispatcher = RecordingDispatcher()
|
||||
app.runtime.dispatcher = dispatcher
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10086", "content": "hello from web"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 200)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["status"], "queued")
|
||||
self.assertEqual(len(dispatcher.messages), 1)
|
||||
self.assertEqual(dispatcher.messages[0].phone_number, "10086")
|
||||
self.assertEqual(dispatcher.messages[0].content, "hello from web")
|
||||
self.assertEqual(dispatcher.messages[0].received_at, "manual")
|
||||
|
||||
def test_send_api_supports_real_sms_send_mode(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
sender = RecordingSmsSender()
|
||||
app.runtime.sms_sender = sender
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps(
|
||||
{"phone_number": "10086", "content": "web real send", "delivery_mode": "real_sms"}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 200)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["status"], "sent")
|
||||
self.assertEqual(payload["delivery_mode"], "real_sms")
|
||||
self.assertEqual(payload["modem_path"], "/org/freedesktop/ModemManager1/Modem/0")
|
||||
self.assertEqual(payload["message_path"], "/org/freedesktop/ModemManager1/SMS/100")
|
||||
self.assertEqual(sender.calls, [{"phone_number": "10086", "content": "web real send"}])
|
||||
|
||||
def test_send_api_reports_real_sms_send_failures(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
sender = RecordingSmsSender(
|
||||
result=SmsSendResult(
|
||||
success=False,
|
||||
phone_number="",
|
||||
content="",
|
||||
error="sms send is not configured",
|
||||
error_category="disabled",
|
||||
)
|
||||
)
|
||||
app.runtime.sms_sender = sender
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps(
|
||||
{"phone_number": "10010", "content": "will fail", "delivery_mode": "real_sms"}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 503)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["error"], "sms_send_failed")
|
||||
self.assertEqual(payload["error_category"], "disabled")
|
||||
self.assertIn("sms send is not configured", payload["detail"])
|
||||
|
||||
def test_send_api_rejects_missing_fields(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"content": "missing phone"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 400)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["error"], "validation_error")
|
||||
self.assertIn("phone_number", payload["detail"])
|
||||
|
||||
def test_static_assets_are_served(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
|
||||
index_response = app.handle_request("GET", "/")
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertIn("DbusSmsForward", index_response["body"])
|
||||
self.assertEqual(index_response["content_type"], "text/html; charset=utf-8")
|
||||
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertIn("fetch", js_response["body"])
|
||||
self.assertEqual(js_response["content_type"], "application/javascript; charset=utf-8")
|
||||
|
||||
def test_handler_class_serves_http_requests(self):
|
||||
app = create_web_app(load_config_dict({"forwarding": {"device_name": "router-http"}}))
|
||||
handler_class = build_handler_class(app)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request_path = Path(tmpdir) / "request.txt"
|
||||
request_path.write_text(
|
||||
"GET /api/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import socketserver
|
||||
import threading
|
||||
from http.server import HTTPServer
|
||||
|
||||
class TestServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
server = TestServer(("127.0.0.1", 0), handler_class)
|
||||
thread = threading.Thread(target=server.handle_request, daemon=True)
|
||||
thread.start()
|
||||
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{server.server_address[1]}/api/health", timeout=5) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(json.loads(body)["status"], "ok")
|
||||
finally:
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dbussmsforward.config import load_config_dict
|
||||
from dbussmsforward.sms import NotificationMessage, SmsMessage
|
||||
from dbussmsforward.web import create_web_app
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.messages.append(sms)
|
||||
return [
|
||||
NotificationMessage(
|
||||
channel="console",
|
||||
title=f"短信转发 {sms.phone_number}",
|
||||
body=f"{sms.content}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WebProductTests(unittest.TestCase):
|
||||
def test_sms_send_response_shape_is_consistent_across_delivery_modes(self):
|
||||
class RealSmsSender:
|
||||
def send(self, phone_number: str, content: str):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(
|
||||
success=True,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
modem_path="/org/freedesktop/ModemManager1/Modem/0",
|
||||
message_path="/org/freedesktop/ModemManager1/SMS/1",
|
||||
error=None,
|
||||
error_category=None,
|
||||
)
|
||||
|
||||
app = create_web_app(load_config_dict({}))
|
||||
app.runtime.dispatcher = RecordingDispatcher()
|
||||
app.runtime.sms_sender = RealSmsSender()
|
||||
|
||||
dispatch_response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10086", "content": "dispatch ok", "delivery_mode": "dispatch"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
real_sms_response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10010", "content": "real sms ok", "delivery_mode": "real_sms"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
dispatch_payload = json.loads(dispatch_response["body"])
|
||||
real_sms_payload = json.loads(real_sms_response["body"])
|
||||
self.assertEqual(dispatch_response["status"], 200)
|
||||
self.assertEqual(real_sms_response["status"], 200)
|
||||
for payload in (dispatch_payload, real_sms_payload):
|
||||
self.assertEqual(payload["ok"], True)
|
||||
self.assertIn(payload["delivery_mode"], {"dispatch", "real_sms"})
|
||||
self.assertIn("status", payload)
|
||||
self.assertIn("phone_number", payload)
|
||||
self.assertIn("received_at", payload)
|
||||
self.assertIn("notification_count", payload)
|
||||
self.assertIn("message_path", payload)
|
||||
self.assertIn("modem_path", payload)
|
||||
self.assertIn("error_category", payload)
|
||||
self.assertIn("detail", payload)
|
||||
|
||||
self.assertEqual(dispatch_payload["notification_count"], 1)
|
||||
self.assertEqual(dispatch_payload["message_path"], None)
|
||||
self.assertEqual(dispatch_payload["modem_path"], None)
|
||||
self.assertEqual(real_sms_payload["notification_count"], 1)
|
||||
self.assertEqual(real_sms_payload["message_path"], "/org/freedesktop/ModemManager1/SMS/1")
|
||||
|
||||
app = create_web_app(
|
||||
load_config_dict(
|
||||
{
|
||||
"forwarding": {"device_name": "router-finish"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
)
|
||||
)
|
||||
app.runtime.dispatcher = RecordingDispatcher()
|
||||
|
||||
send_response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10010", "content": "hello dashboard"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(send_response["status"], 200)
|
||||
|
||||
dashboard = app.handle_request("GET", "/api/dashboard")
|
||||
dashboard_payload = json.loads(dashboard["body"])
|
||||
self.assertEqual(dashboard["status"], 200)
|
||||
self.assertEqual(dashboard_payload["summary"]["manual_send_count"], 1)
|
||||
self.assertEqual(dashboard_payload["summary"]["event_count"], 1)
|
||||
self.assertEqual(dashboard_payload["summary"]["last_sms_phone_number"], "10010")
|
||||
self.assertTrue(dashboard_payload["latest_events"])
|
||||
|
||||
events = app.handle_request("GET", "/api/events")
|
||||
events_payload = json.loads(events["body"])
|
||||
self.assertEqual(events["status"], 200)
|
||||
self.assertEqual(len(events_payload["events"]), 1)
|
||||
self.assertEqual(events_payload["events"][0]["phone_number"], "10010")
|
||||
self.assertEqual(events_payload["events"][0]["notification_count"], 1)
|
||||
self.assertEqual(events_payload["events"][0]["result"], "success")
|
||||
self.assertEqual(events_payload["events"][0]["delivery_mode"], "dispatch")
|
||||
self.assertEqual(events_payload["events"][0]["error_category"], None)
|
||||
|
||||
def test_dashboard_reports_send_failures(self):
|
||||
class FailingDispatcher:
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
raise RuntimeError("dispatch exploded")
|
||||
|
||||
app = create_web_app(load_config_dict({}))
|
||||
app.runtime.dispatcher = FailingDispatcher()
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10000", "content": "boom"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response["status"], 500)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["error"], "dispatch_failed")
|
||||
self.assertEqual(payload["ok"], False)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["phone_number"], "10000")
|
||||
|
||||
dashboard = json.loads(app.handle_request("GET", "/api/dashboard")["body"])
|
||||
self.assertEqual(dashboard["summary"]["failure_count"], 1)
|
||||
self.assertEqual(dashboard["summary"]["event_count"], 1)
|
||||
self.assertEqual(dashboard["latest_events"][0]["result"], "failed")
|
||||
self.assertEqual(dashboard["latest_events"][0]["delivery_mode"], "dispatch")
|
||||
self.assertEqual(dashboard["latest_events"][0]["error_category"], "dispatch_failed")
|
||||
|
||||
def test_static_index_contains_dashboard_sections(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
index_response = app.handle_request("GET", "/")
|
||||
body = index_response["body"]
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertIn("运行总览", body)
|
||||
self.assertIn("最近事件", body)
|
||||
self.assertIn("配置中心", body)
|
||||
self.assertIn("短信测试台", body)
|
||||
|
||||
def test_asset_bundle_contains_dashboard_rendering_logic(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertIn("loadDashboard", js_response["body"])
|
||||
self.assertIn("renderEvents", js_response["body"])
|
||||
|
||||
def test_web_console_exposes_delivery_mode_selector_for_product_use(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
index_response = app.handle_request("GET", "/")
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
css_response = app.handle_request("GET", "/app.css")
|
||||
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertEqual(css_response["status"], 200)
|
||||
self.assertIn("delivery_mode", index_response["body"])
|
||||
self.assertIn("真实短信", index_response["body"])
|
||||
self.assertIn("mode-hint", index_response["body"])
|
||||
self.assertIn("dispatch", js_response["body"])
|
||||
self.assertIn("real_sms", js_response["body"])
|
||||
self.assertIn("formData.get('delivery_mode')", js_response["body"])
|
||||
self.assertIn("需要已配置 ModemManager 发送能力", js_response["body"])
|
||||
self.assertIn("renderResult", js_response["body"])
|
||||
self.assertIn("error_category", js_response["body"])
|
||||
self.assertIn("mode-hint-warning", css_response["body"])
|
||||
|
||||
def test_recent_events_support_operator_filtering_and_failure_visuals(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
index_response = app.handle_request("GET", "/")
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
css_response = app.handle_request("GET", "/app.css")
|
||||
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertEqual(css_response["status"], 200)
|
||||
self.assertIn("事件搜索", index_response["body"])
|
||||
self.assertIn("只看失败", index_response["body"])
|
||||
self.assertIn("event-search", index_response["body"])
|
||||
self.assertIn("getFilteredEvents", js_response["body"])
|
||||
self.assertIn("没有匹配当前筛选条件的事件", js_response["body"])
|
||||
self.assertIn("eventResultFilter", js_response["body"])
|
||||
self.assertIn("event-mode-badge", css_response["body"])
|
||||
self.assertIn("event-failure-meta", css_response["body"])
|
||||
self.assertIn("event-toolbar", css_response["body"])
|
||||
self.assertIn("escapeHtml", js_response["body"])
|
||||
self.assertIn("当前事件数", js_response["body"])
|
||||
self.assertIn("状态、方式", index_response["body"])
|
||||
|
||||
def test_settings_update_reflects_in_dashboard(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = Path(tmpdir) / "appsettings.json"
|
||||
app = create_web_app(load_config_dict({"forwarding": {"device_name": "before"}}), config_path=config_path)
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/settings",
|
||||
body=json.dumps({"forwarding": {"device_name": "after"}}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response["status"], 200)
|
||||
|
||||
dashboard = json.loads(app.handle_request("GET", "/api/dashboard")["body"])
|
||||
self.assertEqual(dashboard["runtime"]["forwarding"]["device_name"], "after")
|
||||
persisted = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["forwarding"]["device_name"], "after")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user