mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-19 12:10:14 +03:00
feat: add miband bot application
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 "")
|
||||
Reference in New Issue
Block a user