rewrite bot in TypeScript and add locale switching

This commit is contained in:
Alex Getman
2026-08-12 13:12:04 +03:00
parent 905b69f568
commit 2cb8740e06
121 changed files with 5230 additions and 15333 deletions
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createBot } from "../src/bot/app.js";
import type { Locale } from "../src/bot/i18n.js";
import { type AppConfig, loadConfig } from "../src/config.js";
import { initHealthDb, initStateDb, setUserLocale, withHealthDb } from "../src/storage/health.js";
type ApiCall = { method: string; payload: Record<string, unknown> };
type KeyboardButton = { text: string; callback_data?: string };
function callbackUpdate(data: string): Parameters<ReturnType<typeof createBot>["handleUpdate"]>[0] {
return {
update_id: Date.now(),
callback_query: {
id: `query-${Date.now()}`,
data,
from: { id: 42, is_bot: false, first_name: "Test" },
chat_instance: "test",
message: {
message_id: 99,
date: Math.floor(Date.now() / 1000),
chat: { id: 42, type: "private", first_name: "Test" },
text: "menu",
},
},
};
}
function configFor(dir: string): AppConfig {
return loadConfig({
TELEGRAM_BOT_TOKEN: "123:abc",
TELEGRAM_ALLOWED_USER_IDS: "42,43",
DATA_DIR: dir,
});
}
async function runCallback(
data: string,
language: Locale = "en",
seed?: (config: AppConfig) => void,
): Promise<ApiCall[]> {
const dir = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-ui-"));
try {
const config = configFor(dir);
writeFileSync(join(dir, "token_42.json"), "{}");
initStateDb(config.botStateDbPath);
setUserLocale(config, 42, language);
initHealthDb(join(dir, "miband_42.db"));
initHealthDb(join(dir, "miband_43.db"));
seed?.(config);
const calls: ApiCall[] = [];
const bot = createBot(config);
bot.botInfo = { id: 999, is_bot: true, first_name: "Test", username: "test_bot" } as never;
bot.api.config.use(async (_previous, method, payload) => {
calls.push({ method: String(method), payload: payload as Record<string, unknown> });
return { ok: true, result: true } as never;
});
await bot.handleUpdate(callbackUpdate(data));
return calls;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function editCall(calls: ApiCall[]): ApiCall {
const call = calls.find((item) => item.method === "editMessageText");
if (!call) throw new Error("editMessageText call was not made");
return call;
}
function rows(call: ApiCall): KeyboardButton[][] {
const markup = call.payload.reply_markup as { inline_keyboard: KeyboardButton[][] };
return markup.inline_keyboard;
}
function button(rowsToRead: KeyboardButton[][], text: string): KeyboardButton {
const found = rowsToRead.flat().find((item) => item.text === text);
if (!found) throw new Error(`Button not found: ${text}`);
return found;
}
describe("bot UI callback parity", () => {
test("opens versus from its legacy menu callback", async () => {
const call = editCall(await runCallback("menu:versus"));
expect(call.payload.text).toBe("📊 <b>Compare activity:</b>");
expect(button(rows(call), "Today").callback_data).toBe("versus:1");
expect(button(rows(call), "📊 Weekly").callback_data).toBe("versus:7");
});
test("preserves the family return destination", async () => {
const weekly = editCall(await runCallback("menu:family:weekly"));
expect(button(rows(weekly), "⬅️ Back").callback_data).toBe("menu:weekly_back");
const trends = editCall(await runCallback("menu:family:trends"));
expect(button(rows(trends), "⬅️ Back").callback_data).toBe("menu:trends");
const weeklyBack = editCall(await runCallback("menu:weekly_back"));
expect(String(weeklyBack.payload.text)).toContain("📊 Weekly summary:");
expect(button(rows(weeklyBack), "👪 Family").callback_data).toBe("menu:family:weekly");
});
test("marks the selected trends period", async () => {
const call = editCall(await runCallback("period:30d"));
expect(button(rows(call), "· 30 days ·").callback_data).toBe("period:30d");
expect(button(rows(call), "All time").callback_data).toBe("period:all");
});
test("uses English as the default main keyboard", async () => {
const call = editCall(await runCallback("menu:main"));
expect(button(rows(call), "😴 Sleep").callback_data).toBe("menu:sleep");
expect(button(rows(call), "📊 Weekly").callback_data).toBe("menu:trends");
expect(button(rows(call), "⚙️ Settings").callback_data).toBe("menu:more");
});
test("offers Russian and Spanish in the language settings", async () => {
const languageMenu = editCall(await runCallback("menu:language"));
expect(button(rows(languageMenu), "Русский").callback_data).toBe("locale:ru");
expect(button(rows(languageMenu), "Español").callback_data).toBe("locale:es");
const spanish = editCall(await runCallback("locale:es"));
expect(button(rows(spanish), "· Español ·").callback_data).toBe("locale:es");
const spanishMain = editCall(await runCallback("menu:main", "es"));
expect(button(rows(spanishMain), "😴 Sueño").callback_data).toBe("menu:sleep");
});
test("renders Spanish trend labels", async () => {
const call = editCall(await runCallback("menu:trends", "es"));
expect(String(call.payload.text)).toContain("📊 Tendencias");
expect(button(rows(call), "30 días").callback_data).toBe("period:30d");
expect(button(rows(call), "👪 Familia").callback_data).toBe("menu:family:trends");
});
test("does not append /100 to an unavailable sleep score", async () => {
const call = editCall(
await runCallback("menu:sleep", "ru", (config) => {
withHealthDb(config, 42, (database) => {
database
.prepare(
"INSERT INTO sleep_daily (date,light_sleep_min,deep_sleep_min,start_time,end_time,total_duration_min,sleep_score) VALUES (?,?,?,?,?,?,?)",
)
.run("2026-08-12", 120, 120, 1_755_000_000, 1_755_025_200, 240, 0);
});
}),
);
expect(String(call.payload.text)).toContain("Качество <b>н/д</b>");
expect(String(call.payload.text)).not.toContain("Качество <b>н/д / 100</b>");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { ConfigurationError, loadConfig } from "../src/config.js";
describe("loadConfig", () => {
test("keeps http-only mode usable without Telegram credentials", () => {
const config = loadConfig({ BOT_MODE: "http-only", DATABASE_URL: ":memory:", QUERY_DURATION: "2" });
expect(config.BOT_MODE).toBe("http-only");
expect(config.QUERY_DURATION).toBe(2);
expect(config.dataDir).toContain("data");
});
test("parses the personal allowlist", () => {
const config = loadConfig({ TELEGRAM_BOT_TOKEN: "123:abc", TELEGRAM_ALLOWED_USER_IDS: "42, 7" });
expect(config.allowedUserIds).toEqual([42, 7]);
});
test("rejects malformed IDs and incomplete webhook settings", () => {
expect(() => loadConfig({ TELEGRAM_BOT_TOKEN: "123:abc", TELEGRAM_ALLOWED_USER_IDS: "42,nope" })).toThrow(
ConfigurationError,
);
expect(() =>
loadConfig({ TELEGRAM_BOT_TOKEN: "123:abc", BOT_MODE: "webhook", TELEGRAM_ALLOWED_USER_IDS: "42" }),
).toThrow(ConfigurationError);
});
test("treats empty strings as unset", () => {
expect(loadConfig({ BOT_MODE: "http-only", TELEGRAM_BOT_TOKEN: "" }).TELEGRAM_BOT_TOKEN).toBeUndefined();
});
test("parses textual booleans instead of coercing false to true", () => {
const config = loadConfig({ BOT_MODE: "http-only", ENABLE_FDS_SLEEP_DETAILS: "false" });
expect(config.ENABLE_FDS_SLEEP_DETAILS).toBe(false);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, test } from "bun:test";
import { parseAllDaySleepBytes } from "../src/xiaomi/fds.js";
describe("FDS parser", () => {
test("rejects truncated sleep payloads", () => {
expect(parseAllDaySleepBytes(new Uint8Array([0, 0, 0, 0, 0, 1, 0, 0, 0]))).toBeNull();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, test } from "bun:test";
import { esc, minutes, stepBar } from "../src/bot/formatting.js";
describe("bot formatting", () => {
test("escapes Telegram HTML and formats health values", () => {
expect(esc('<secret> & "value"')).toBe("&lt;secret&gt; &amp; &quot;value&quot;");
expect(minutes(396)).toBe("6 h 36 min");
expect(stepBar(5000)).toContain("50%");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { initHealthDb, zipExport } from "../src/storage/health.js";
import { openDatabase } from "../src/storage/kv.js";
function tempDir(): string {
return mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-ts-"));
}
describe("health storage", () => {
test("creates the health schema and exports CSV files as ZIP", async () => {
const dir = tempDir();
const dbPath = join(dir, "miband_42.db");
initHealthDb(dbPath);
const database = openDatabase(dbPath);
database.sqlite.query("INSERT INTO steps_daily (date,total_steps) VALUES (?,?)").run("2026-08-11", 1234);
database.close();
const config = {
dataDir: dir,
dbPath,
statusPath: join(dir, "status.json"),
botStateDbPath: join(dir, "state.db"),
allowedUserIds: [42],
BOT_MODE: "http-only",
QUERY_DURATION: 2,
} as never;
const archive = await zipExport(config, 42);
expect(archive).not.toBeNull();
expect(new Uint8Array(archive ?? []).length).toBeGreaterThan(0);
rmSync(dir, { recursive: true, force: true });
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { withExclusiveFileLock } from "../src/storage/secure-files.js";
describe("file locks", () => {
test("releases the lock directory after the action", async () => {
const directory = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-lock-"));
const path = join(directory, "sync.lock");
await withExclusiveFileLock(path, async () => "first");
await expect(withExclusiveFileLock(path, async () => "second")).resolves.toBe("second");
rmSync(directory, { recursive: true, force: true });
});
});
-286
View File
@@ -1,286 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
import asyncio
import json
import os
import time
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
import miband_tracker.bot.app as bot_app
from miband_tracker import storage
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 _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_service_menu_does_not_expose_invites_or_second_user(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = _settings(tmp_path)
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(settings.canonical_user_db_path()))
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(tmp_path)
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()
call_args = run_sync.await_args[0]
assert call_args[0] == 123
assert call_args[1].query_duration == 30
show_main_menu.assert_awaited_once_with(update, context)
def test_main_menu_renders_extended_metrics(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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-26", 451, 22.0, 296.0, 1),
)
conn.execute("INSERT INTO heart_rate (timestamp, value) VALUES (?, ?)", (1779768600, 73))
conn.execute("INSERT INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)", (1779768900, 99.0, "latest"))
conn.execute("INSERT INTO stress (timestamp, value) VALUES (?, ?)", (1779757800, 29))
conn.execute(
"""
INSERT INTO calories_daily
(date, total_cal, active_cal, valid_stand_hours, intensity_minutes, last_sync)
VALUES (?, ?, ?, ?, ?, ?)
""",
("2026-05-26", 55.0, None, 1, 6, 1),
)
conn.commit()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(db_path))
rendered = bot_app.main_menu_text()
assert "451" in rendered
assert "99%" in rendered
assert "🧘" in rendered
assert "22</b> ккал" in rendered
def test_workouts_text_renders_recent_workout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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 workouts
(workout_id, sport_type, start_time, end_time, duration_sec,
calories, avg_hr, max_hr, min_hr, watermark, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("w1", "free_training", 1779752903, 1779753050, 142, 7.0, 95, 152, 80, 1, "{}"),
)
conn.commit()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(db_path))
rendered = bot_app.workouts_text()
assert "Тренировки" in rendered
assert "Свободная" in rendered
assert "95 bpm" in rendered
def test_sleep_text_has_no_fds_hint_or_calendar_button(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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 sleep_daily
(date, light_sleep_min, deep_sleep_min, start_time, end_time,
rem_sleep_min, awake_min, total_duration_min, sleep_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("2026-05-26", 137, 123, 1779744540, 1779770460, 136, 0, 396, 64),
)
conn.commit()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(db_path))
rendered = bot_app.latest_sleep_text()
keyboard_labels = [
button.text
for row in bot_app.back_keyboard().inline_keyboard
for button in row
]
assert "Детали подтягиваются" not in rendered
assert "FDS" not in rendered
assert "Календарь" not in keyboard_labels
def test_trends_screen_labels_and_keyboard(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
settings = _settings(tmp_path)
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(settings.canonical_user_db_path()))
rendered = bot_app.period_text(3650)
keyboard = bot_app.trends_keyboard(3650)
labels = [
button.text
for row in keyboard.inline_keyboard
for button in row
]
assert "Тренды" in rendered
assert "Все время" in rendered
assert not any("Аналитика" in label for label in labels)
assert not any("Детали сна" in label for label in labels)
assert any("Все время" in label for label in labels)
@pytest.mark.asyncio
async def test_auto_refresh_updates_saved_menu_after_status_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = _settings(tmp_path)
storage.init_health_db(settings.canonical_user_db_path())
storage.init_state_db(settings)
storage.set_user_menu_msg_id(settings, 123, 99)
settings.canonical_user_status_path(123).write_text("{}", encoding="utf-8")
class FakeBot:
edit_message_text = AsyncMock()
class FakeApp:
bot = FakeBot()
monkeypatch.setattr(bot_app, "SETTINGS", settings)
monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123)
monkeypatch.setattr(bot_app, "DB_PATH", str(settings.canonical_user_db_path()))
monkeypatch.setattr(bot_app, "AUTO_MENU_REFRESH_INTERVAL", 0.01)
task = asyncio.create_task(bot_app.auto_refresh_main_menu_loop(FakeApp()))
await asyncio.sleep(0.02)
now = time.time() + 1
path = settings.canonical_user_status_path(123)
path.write_text('{"last_sync": 1}', encoding="utf-8")
os.utime(path, (now, now))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
FakeApp.bot.edit_message_text.assert_awaited()
-76
View File
@@ -1,76 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from pathlib import Path
import pytest
from miband_tracker.config import ConfigError, Settings, parse_user_ids
def test_parse_user_ids_accepts_multiple_values() -> None:
assert parse_user_ids("1,2", required=True) == [1, 2]
assert parse_user_ids("1", required=True) == [1]
def test_parse_user_ids_rejects_invalid_values() -> None:
with pytest.raises(ConfigError):
parse_user_ids("1,invalid", 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_ids=[user_id],
sync_interval=900,
query_duration=2,
enable_fds_sleep_details=True,
)
assert settings.telegram_allowed_user_id == user_id
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_ids=[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_IDS", "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_IDS", "123")
monkeypatch.setenv("QUERY_DURATION", "0")
with pytest.raises(ConfigError, match="QUERY_DURATION"):
Settings.from_env()
-58
View File
@@ -1,58 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock
from telegram import Update, User
from miband_tracker.bot import app
from miband_tracker.config import Settings
def test_dynamic_whitelisting(tmp_path: Path) -> None:
# Setup temporary directories and config
data_dir = tmp_path / "data"
data_dir.mkdir()
# Override settings in app and os.environ
os.environ["DATA_DIR"] = str(data_dir)
os.environ["TELEGRAM_ALLOWED_USER_ID"] = ""
app.SETTINGS = Settings.from_env()
app.ALLOWED_USER_ID = None
# Verify initial state
assert app.ALLOWED_USER_ID is None
assert not (data_dir / "allowed_user.id").exists()
# 1. First user tries to access
user_1 = MagicMock(spec=User)
user_1.id = 999999
update_1 = MagicMock(spec=Update)
update_1.effective_user = user_1
# First access should be allowed and should bind user_1
assert app.is_allowed(update_1) is True
assert app.ALLOWED_USER_ID == 999999
assert (data_dir / "allowed_user.id").exists()
assert (data_dir / "allowed_user.id").read_text(encoding="utf-8").strip() == "999999"
# 2. Second user tries to access
user_2 = MagicMock(spec=User)
user_2.id = 888888
update_2 = MagicMock(spec=Update)
update_2.effective_user = user_2
# Access for user_2 must be blocked
assert app.is_allowed(update_2) is False
# 3. First user accesses again
assert app.is_allowed(update_1) is True
# 4. Verify settings reloading
reloaded_settings = Settings.from_env()
assert reloaded_settings.telegram_allowed_user_id == 999999
-36
View File
@@ -1,36 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
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
-38
View File
@@ -1,38 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
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)
-35
View File
@@ -1,35 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
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
-19
View File
@@ -1,19 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import io
from miband_tracker.stdio import safe_print
def test_safe_print_does_not_crash_on_non_ascii_with_charmap_stream() -> None:
raw = io.BytesIO()
stream = io.TextIOWrapper(raw, encoding="cp1251", errors="strict")
safe_print("API 业务错误", file=stream, flush=True)
stream.flush()
assert b"API " in raw.getvalue()
assert b"\\u4e1a" in raw.getvalue()
-114
View File
@@ -1,114 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
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",
"stress",
"calories_daily",
"weight",
"workouts",
}.issubset(tables)
def test_extended_health_tables_have_expected_columns(tmp_path: Path) -> None:
db_path = tmp_path / "miband_123.db"
storage.init_health_db(db_path)
with storage.sqlite_conn(db_path, row_factory=False) as conn:
columns = {
table: {
row[1]
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
}
for table in ("stress", "calories_daily", "weight", "workouts")
}
assert columns["stress"] == {"timestamp", "value"}
assert {
"date",
"total_cal",
"active_cal",
"valid_stand_hours",
"intensity_minutes",
"last_sync",
}.issubset(columns["calories_daily"])
assert {"timestamp", "weight_kg", "bmi", "body_fat_pct"}.issubset(columns["weight"])
assert {
"workout_id",
"sport_type",
"start_time",
"end_time",
"duration_sec",
"calories",
"avg_hr",
"max_hr",
"min_hr",
"watermark",
"raw_json",
}.issubset(columns["workouts"])
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.execute(
"INSERT INTO stress (timestamp, value) VALUES (?, ?)",
(1779760000, 42),
)
conn.execute(
"""
INSERT INTO workouts
(workout_id, sport_type, start_time, end_time, duration_sec,
calories, avg_hr, max_hr, min_hr, watermark, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("w1", "free_training", 1779760000, 1779760600, 600, 55.0, 110, 130, 90, 1, "{}"),
)
conn.commit()
archive = storage.zip_export(settings)
assert archive.getbuffer().nbytes > 0
assert b"steps_daily.csv" in archive.getvalue()
assert b"stress.csv" in archive.getvalue()
assert b"workouts.csv" in archive.getvalue()
-77
View File
@@ -1,77 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from pathlib import Path
import pytest
from miband_tracker.config import Settings
from miband_tracker.storage import init_health_db, sqlite_conn
from miband_tracker.sync import _sync_workouts, 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 "")
@pytest.mark.asyncio
async def test_sync_workouts_inserts_watermark_records(tmp_path: Path) -> None:
db_path = tmp_path / "miband_123.db"
init_health_db(db_path)
class FakeClient:
async def _request(self, method, path, params):
assert method == "GET"
assert path == "/app/v1/data/get_sport_records_by_watermark"
assert params["relative_uid"] == 456
return {
"result": {
"has_more": False,
"sport_records": [
{
"sid": "w1",
"key": "free_training",
"time": 1779752903,
"watermark": 12345,
"value": (
'{"start_time": 1779752903, "end_time": 1779753050, '
'"duration": 142, "calories": 7, "avg_hrm": 95, '
'"max_hrm": 152, "min_hrm": 80}'
),
}
],
}
}
counters = {"workouts": 0}
with sqlite_conn(db_path, row_factory=False) as conn:
cursor = conn.cursor()
await _sync_workouts(FakeClient(), cursor, counters, 456)
conn.commit()
with sqlite_conn(db_path) as conn:
row = conn.execute("SELECT * FROM workouts WHERE workout_id = ?", ("w1",)).fetchone()
assert counters["workouts"] == 1
assert row is not None
assert row["sport_type"] == "free_training"
assert row["duration_sec"] == 142
assert row["avg_hr"] == 95
assert row["watermark"] == 12345
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { buildEncryptedParams, computeSignedNonce, decryptData, encryptData } from "../src/xiaomi/client.js";
describe("Xiaomi crypto", () => {
test("round-trips encrypted payloads and request params", () => {
const ssecurity = Buffer.from("test-ssecurity-key").toString("base64");
const nonce = Buffer.from("123456789012").toString("base64");
const signed = computeSignedNonce(ssecurity, nonce);
expect(decryptData(signed, encryptData(signed, '{"中文":"тест"}'))).toBe('{"中文":"тест"}');
const params = buildEncryptedParams("POST", "/test", ssecurity, { test: "hello", num: 42 });
expect(JSON.parse(decryptData(computeSignedNonce(ssecurity, params._nonce ?? ""), params.data ?? ""))).toEqual({
test: "hello",
num: 42,
});
expect(params.signature).toBeString();
expect(params.rc4_hash__).toBeString();
});
});