feat: add miband bot application

This commit is contained in:
Alex
2026-05-24 21:53:04 +03:00
parent 23876f70e2
commit 7962c423b4
71 changed files with 12533 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
import miband_tracker.bot.app as bot_app
from miband_tracker.config import Settings
from miband_tracker.sync import SyncResult
class FakeUser:
id = 123
class FakeUpdate:
effective_user = FakeUser()
message = object()
callback_query = None
def test_service_menu_does_not_expose_invites_or_second_user() -> None:
keyboard = bot_app.more_keyboard()
labels = [
button.text
for row in keyboard.inline_keyboard
for button in row
]
rendered = "\n".join(labels + [bot_app.more_text()])
assert "Принять приглашения" not in rendered
assert "Приглашения" not in rendered
assert "втор" not in rendered.lower()
@pytest.mark.asyncio
async def test_cmd_start_without_token_shows_onboarding(monkeypatch: pytest.MonkeyPatch) -> None:
update = FakeUpdate()
context = object()
show_onboarding = AsyncMock()
show_main_menu = AsyncMock()
monkeypatch.setattr(bot_app, "is_allowed", lambda _: True)
monkeypatch.setattr(bot_app, "has_xiaomi_token", lambda: False)
monkeypatch.setattr(bot_app, "safe_delete", AsyncMock())
monkeypatch.setattr(bot_app, "show_onboarding", show_onboarding)
monkeypatch.setattr(bot_app, "show_main_menu", show_main_menu)
await bot_app.cmd_start(update, context)
show_onboarding.assert_awaited_once_with(update, context, force_new=True)
show_main_menu.assert_not_awaited()
@pytest.mark.asyncio
async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
update = FakeUpdate()
context = object()
token_path = tmp_path / "token_123.json"
class FakeToken:
def model_dump(self):
return {
"user_id": "456",
"c_user_id": "",
"service_token": "service",
"ssecurity": "sec",
"pass_token": "pass",
"device_id": "device",
}
class FakeAuth:
async def login_qr(self, *, qr_callback, max_wait):
await qr_callback("https://example.test/qr.png", "https://example.test/login")
return FakeToken()
async def close(self):
return None
settings = Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=123,
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
run_sync = AsyncMock(return_value=SyncResult(True, user_id=123))
show_main_menu = AsyncMock()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "AUTH_LOCK", asyncio.Lock())
monkeypatch.setattr(bot_app, "SYNC_LOCK", asyncio.Lock())
monkeypatch.setattr(bot_app, "XiaomiAuth", FakeAuth)
monkeypatch.setattr(bot_app, "update_menu", AsyncMock())
monkeypatch.setattr(bot_app, "run_sync", run_sync)
monkeypatch.setattr(bot_app, "show_main_menu", show_main_menu)
await bot_app.start_xiaomi_login(update, context)
data = json.loads(token_path.read_text(encoding="utf-8"))
assert data["user_id"] == "456"
assert data["service_token"] == "service"
run_sync.assert_awaited_once_with(123, settings)
show_main_menu.assert_awaited_once_with(update, context)
+67
View File
@@ -0,0 +1,67 @@
from pathlib import Path
import pytest
from miband_tracker.config import ConfigError, Settings, parse_single_user_id
def test_parse_single_user_id_rejects_multiple_values() -> None:
with pytest.raises(ConfigError):
parse_single_user_id("1,2", required=True)
def test_settings_user_paths_prefer_existing_user_files(tmp_path: Path) -> None:
user_id = 123
(tmp_path / f"miband_{user_id}.db").write_text("", encoding="utf-8")
(tmp_path / f"status_{user_id}.json").write_text("{}", encoding="utf-8")
(tmp_path / f"token_{user_id}.json").write_text("{}", encoding="utf-8")
settings = Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=user_id,
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
assert settings.user_db_path() == tmp_path / f"miband_{user_id}.db"
assert settings.user_status_path() == tmp_path / f"status_{user_id}.json"
assert settings.token_path() == tmp_path / f"token_{user_id}.json"
def test_settings_falls_back_to_legacy_db_and_status(tmp_path: Path) -> None:
settings = Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=123,
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
assert settings.user_db_path() == tmp_path / "miband.db"
assert settings.user_status_path() == tmp_path / "status.json"
assert settings.canonical_user_db_path() == tmp_path / "miband_123.db"
def test_settings_from_env_rejects_invalid_interval(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TELEGRAM_ALLOWED_USER_ID", "123")
monkeypatch.setenv("SYNC_INTERVAL", "soon")
with pytest.raises(ConfigError, match="SYNC_INTERVAL"):
Settings.from_env()
def test_settings_from_env_rejects_zero_query_duration(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TELEGRAM_ALLOWED_USER_ID", "123")
monkeypatch.setenv("QUERY_DURATION", "0")
with pytest.raises(ConfigError, match="QUERY_DURATION"):
Settings.from_env()
+33
View File
@@ -0,0 +1,33 @@
import struct
from miband_tracker.fds import parse_all_day_sleep_bytes
def test_parse_all_day_sleep_bytes_reads_hr_and_spo2_records() -> None:
blob = bytearray()
blob += struct.pack("<I", 1_700_000_000)
blob += bytes([12, 2, 0])
blob += bytes([0b00000000, 0b11000000])
blob += bytes([1])
blob += struct.pack("<I", 1_700_000_100)
blob += struct.pack("<I", 1_700_000_500)
blob += bytes([80, 90])
blob += struct.pack("<I", 120)
blob += struct.pack("<I", 400)
blob += struct.pack("<I", 1_700_000_090)
blob += struct.pack("<I", 1_700_000_520)
blob += struct.pack("<hhI", 60, 2, 1_700_000_100)
blob += bytes([61, 62])
blob += struct.pack("<hhI", 60, 2, 1_700_000_100)
blob += bytes([97, 98])
parsed = parse_all_day_sleep_bytes(bytes(blob))
assert parsed is not None
assert parsed["records"]["heart_rate"] == [(1_700_000_100, 61), (1_700_000_160, 62)]
assert parsed["records"]["spo2"] == [(1_700_000_100, 97), (1_700_000_160, 98)]
def test_parse_all_day_sleep_bytes_rejects_malformed_payloads() -> None:
for blob in (b"", bytes(9), bytes(10), bytes(20)):
assert parse_all_day_sleep_bytes(blob) is None
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
from miband_tracker.lock import LockUnavailable, exclusive_file_lock
def test_exclusive_file_lock_rejects_second_process(tmp_path: Path) -> None:
lock_path = tmp_path / "sync.lock"
script = (
"import sys, time\n"
"from pathlib import Path\n"
"from miband_tracker.lock import exclusive_file_lock\n"
"with exclusive_file_lock(Path(sys.argv[1])):\n"
" print('ready', flush=True)\n"
" time.sleep(5)\n"
)
proc = subprocess.Popen(
[sys.executable, "-c", script, str(lock_path)],
stdout=subprocess.PIPE,
text=True,
)
try:
assert proc.stdout is not None
assert proc.stdout.readline().strip() == "ready"
with pytest.raises(LockUnavailable):
with exclusive_file_lock(lock_path):
pass
finally:
proc.terminate()
proc.wait(timeout=5)
+32
View File
@@ -0,0 +1,32 @@
import json
import os
from pathlib import Path
from mi_fitness.models import AuthToken
from miband_tracker.secure_files import save_auth_token, write_secret_json
def test_write_secret_json_uses_private_file_mode(tmp_path: Path) -> None:
path = tmp_path / "token.json"
write_secret_json(path, {"service_token": "secret"})
assert json.loads(path.read_text(encoding="utf-8")) == {"service_token": "secret"}
assert stat_mode(path) == 0o600
def test_save_auth_token_preserves_target_relative_uid(tmp_path: Path) -> None:
path = tmp_path / "token.json"
write_secret_json(path, {"target_relative_uid": "42"})
save_auth_token(AuthToken(user_id="11", service_token="svc", ssecurity="sec"), path)
data = json.loads(path.read_text(encoding="utf-8"))
assert data["user_id"] == "11"
assert data["target_relative_uid"] == "42"
assert stat_mode(path) == 0o600
def stat_mode(path: Path) -> int:
return os.stat(path).st_mode & 0o777
+49
View File
@@ -0,0 +1,49 @@
from pathlib import Path
from miband_tracker import storage
from miband_tracker.config import Settings
def _settings(tmp_path: Path) -> Settings:
return Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=123,
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
def test_init_health_db_is_idempotent(tmp_path: Path) -> None:
db_path = tmp_path / "miband_123.db"
storage.init_health_db(db_path)
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path) as conn:
tables = {
row["name"]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
}
assert {"steps_daily", "sleep_daily", "heart_rate", "blood_oxygen"}.issubset(tables)
def test_zip_export_includes_non_empty_tables_only(tmp_path: Path) -> None:
settings = _settings(tmp_path)
db_path = settings.canonical_user_db_path()
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path, row_factory=False) as conn:
conn.execute(
"INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)",
("2026-05-24", 1000, 10.0, 800.0, 1),
)
conn.commit()
archive = storage.zip_export(settings)
assert archive.getbuffer().nbytes > 0
assert b"steps_daily.csv" in archive.getvalue()
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
import pytest
from miband_tracker.config import Settings
from miband_tracker.sync import run_sync
@pytest.mark.asyncio
async def test_run_sync_missing_token_returns_failed_result(tmp_path: Path) -> None:
settings = Settings(
data_dir=tmp_path,
db_path=tmp_path / "miband.db",
status_path=tmp_path / "status.json",
bot_state_db_path=tmp_path / "fitness_bot_state.db",
telegram_bot_token="token",
telegram_allowed_user_id=123,
sync_interval=0,
query_duration=2,
enable_fds_sleep_details=True,
)
result = await run_sync(settings=settings)
assert not result.success
assert result.user_id == 123
assert "Token file not found" in (result.error or "")