feat: publish dbussmsforward refactor

This commit is contained in:
2026-05-08 23:54:03 +08:00
commit 9f1ee9ffe6
77 changed files with 15585 additions and 0 deletions
View File
+12
View File
@@ -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"
}
]
+601
View File
@@ -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()
+78
View File
@@ -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()
+150
View File
@@ -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()
+158
View File
@@ -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()
+53
View File
@@ -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()
+106
View File
@@ -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()
+37
View File
@@ -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()
+683
View File
@@ -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()
+229
View File
@@ -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()
+223
View File
@@ -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()