From d7ac03468d922f5b06a53c15cd62dabd6655edef Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 09:45:33 +0300 Subject: [PATCH 1/2] =?UTF-8?q?test:=20get=5Fnext=5Fcards=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B5=D0=B9?= =?UTF-8?q?=20in-memory=20SQLite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/tests/test_cards_db.py | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 bot/tests/test_cards_db.py diff --git a/bot/tests/test_cards_db.py b/bot/tests/test_cards_db.py new file mode 100644 index 0000000..6bcdce6 --- /dev/null +++ b/bot/tests/test_cards_db.py @@ -0,0 +1,63 @@ +import os +import sys + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from cards import get_next_cards +from models import Card + + +def add_cards(db, count: int) -> None: + for i in range(1, count + 1): + db.add( + Card( + id=i, + question=f"Вопрос {i}", + answer=f"Ответ {i}", + category="Python", + tags="[]", + difficulty="easy", + ) + ) + db.commit() + + +@pytest.fixture +def db(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Card.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + try: + yield session + finally: + session.close() + engine.dispose() + + +class TestGetNextCardsRealDb: + def test_returns_cards_after_last_id_in_order(self, db): + add_cards(db, 6) + result = get_next_cards(db, 3, last_id=2) + assert [c.id for c in result] == [3, 4, 5] + + def test_wraps_around_to_start(self, db): + add_cards(db, 5) + result = get_next_cards(db, 3, last_id=4) + assert [c.id for c in result] == [5, 1, 2] + + def test_last_id_beyond_max_starts_from_beginning(self, db): + add_cards(db, 5) + result = get_next_cards(db, 3, last_id=100) + assert [c.id for c in result] == [1, 2, 3] + + def test_empty_db_returns_empty(self, db): + assert get_next_cards(db, 3, last_id=0) == [] From 30eb01d7ed6d5ec4c0a75b8570e7db973d715a7e Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 10:36:02 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20config=20=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B0=D0=B5=D1=82,=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=D0=B0=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=20=D0=B8=D0=BB=D0=B8=20=D0=BD=D0=B5=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/config.py | 31 +++++++++++++++++++++++++++++-- bot/tests/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 bot/tests/test_config.py diff --git a/bot/config.py b/bot/config.py index 8a79626..019b5ed 100644 --- a/bot/config.py +++ b/bot/config.py @@ -3,9 +3,36 @@ load_dotenv("../.env") + +def _require(name: str) -> str: + """Возвращает обязательную переменную окружения или бросает понятную ошибку.""" + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Не задана переменная окружения {name}") + return value + + +def _parse_admin_ids(raw: str) -> list[int]: + """Разбирает список id через запятую; пустые элементы пропускает.""" + ids: list[int] = [] + for item in raw.split(","): + item = item.strip() + if not item: + continue + try: + ids.append(int(item)) + except ValueError: + raise RuntimeError( + f"ADMIN_IDS: «{item}» не число (нужны id через запятую)" + ) from None + if not ids: + raise RuntimeError("ADMIN_IDS не содержит ни одного id") + return ids + + class Settings: - bot_token: str = os.environ["BOT_TOKEN"] - admin_ids: list[int] = [int(i) for i in os.environ["ADMIN_IDS"].split(",")] + bot_token: str = _require("BOT_TOKEN") + admin_ids: list[int] = _parse_admin_ids(_require("ADMIN_IDS")) database_url: str = os.getenv("DATABASE_URL", "sqlite:///./data/cards.db") cards_per_session: int = int(os.getenv("CARDS_PER_SESSION", "4")) # schedule_interval_hours: int = int(os.getenv("SCHEDULE_INTERVAL_HOURS", "5")) # не нужен при настройке почасового крона diff --git a/bot/tests/test_config.py b/bot/tests/test_config.py new file mode 100644 index 0000000..3ed305c --- /dev/null +++ b/bot/tests/test_config.py @@ -0,0 +1,37 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import config + + +class TestRequire: + def test_returns_value(self, monkeypatch): + monkeypatch.setenv("TEST_REQUIRED_VAR", "abc") + assert config._require("TEST_REQUIRED_VAR") == "abc" + + def test_missing_variable_names_it(self, monkeypatch): + monkeypatch.delenv("TEST_REQUIRED_VAR", raising=False) + with pytest.raises(RuntimeError, match="TEST_REQUIRED_VAR"): + config._require("TEST_REQUIRED_VAR") + + def test_empty_variable_rejected(self, monkeypatch): + monkeypatch.setenv("TEST_REQUIRED_VAR", " ") + with pytest.raises(RuntimeError, match="TEST_REQUIRED_VAR"): + config._require("TEST_REQUIRED_VAR") + + +class TestParseAdminIds: + def test_valid_list(self): + assert config._parse_admin_ids("1, 22,333") == [1, 22, 333] + + def test_skips_empty_items(self): + assert config._parse_admin_ids("1,2,") == [1, 2] + + @pytest.mark.parametrize("raw", ["", " ", ",", "1,abc"]) + def test_invalid_rejected_with_name(self, raw): + with pytest.raises(RuntimeError, match="ADMIN_IDS"): + config._parse_admin_ids(raw)