chore: update development dependencies and improve test coverage
Some checks failed
CI / lint-and-test (push) Failing after 11s
Some checks failed
CI / lint-and-test (push) Failing after 11s
- Upgraded `pytest-asyncio` to version 1.0 to ensure compatibility with the latest features and improvements. - Increased the coverage threshold in pytest configuration to 80%, enhancing the quality assurance process. - Added a new `conftest.py` file to manage shared fixtures and improve test organization. - Introduced multiple new test files to cover various components, ensuring comprehensive test coverage across the application. - Updated the `.coverage` file to reflect the latest coverage metrics.
This commit is contained in:
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Pytest configuration and shared fixtures.
|
||||
|
||||
Disposes the global DB engine after each test to avoid ResourceWarning
|
||||
from unclosed sqlite3 connections when tests touch duty_teller.db.session.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _dispose_db_engine_after_test():
|
||||
"""After each test, dispose the global engine so connections are closed."""
|
||||
yield
|
||||
try:
|
||||
import duty_teller.db.session as session_mod
|
||||
|
||||
if session_mod._engine is not None:
|
||||
session_mod._engine.dispose()
|
||||
session_mod._engine = None
|
||||
session_mod._SessionLocal = None
|
||||
except Exception:
|
||||
pass
|
||||
99
tests/test_api_dependencies.py
Normal file
99
tests/test_api_dependencies.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for duty_teller.api.dependencies (lang, auth error, date validation, private client)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import duty_teller.api.dependencies as deps
|
||||
import duty_teller.config as config
|
||||
|
||||
|
||||
class TestLangFromAcceptLanguage:
|
||||
"""Tests for _lang_from_accept_language."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_ru_ru_returns_ru(self):
|
||||
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
|
||||
|
||||
def test_en_us_returns_en(self):
|
||||
assert deps._lang_from_accept_language("en-US") == "en"
|
||||
|
||||
def test_invalid_fallback_to_en(self):
|
||||
assert deps._lang_from_accept_language("zz") == "en"
|
||||
assert deps._lang_from_accept_language("x") == "en"
|
||||
|
||||
|
||||
class TestAuthErrorDetail:
|
||||
"""Tests for _auth_error_detail."""
|
||||
|
||||
def test_hash_mismatch_uses_bad_signature_key(self):
|
||||
with patch("duty_teller.api.dependencies.t") as mock_t:
|
||||
mock_t.return_value = "Bad signature"
|
||||
result = deps._auth_error_detail("hash_mismatch", "en")
|
||||
assert result == "Bad signature"
|
||||
mock_t.assert_called_once_with("en", "api.auth_bad_signature")
|
||||
|
||||
def test_other_reason_uses_auth_invalid_key(self):
|
||||
with patch("duty_teller.api.dependencies.t") as mock_t:
|
||||
mock_t.return_value = "Invalid auth"
|
||||
result = deps._auth_error_detail("expired", "ru")
|
||||
assert result == "Invalid auth"
|
||||
mock_t.assert_called_once_with("ru", "api.auth_invalid")
|
||||
|
||||
|
||||
class TestValidateDutyDates:
|
||||
"""Tests for _validate_duty_dates."""
|
||||
|
||||
def test_valid_range_no_exception(self):
|
||||
deps._validate_duty_dates("2025-01-01", "2025-01-31", "en")
|
||||
|
||||
def test_bad_format_raises_400_with_i18n(self):
|
||||
with patch("duty_teller.api.dependencies.t") as mock_t:
|
||||
mock_t.return_value = "Bad format message"
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
deps._validate_duty_dates("01-01-2025", "2025-01-31", "en")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "Bad format message"
|
||||
mock_t.assert_called_with("en", "dates.bad_format")
|
||||
|
||||
def test_from_after_to_raises_400_with_i18n(self):
|
||||
with patch("duty_teller.api.dependencies.t") as mock_t:
|
||||
mock_t.return_value = "From after to message"
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
deps._validate_duty_dates("2025-02-01", "2025-01-01", "ru")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "From after to message"
|
||||
mock_t.assert_called_with("ru", "dates.from_after_to")
|
||||
|
||||
|
||||
class TestIsPrivateClient:
|
||||
"""Tests for _is_private_client."""
|
||||
|
||||
def test_loopback_true(self):
|
||||
assert deps._is_private_client("127.0.0.1") is True
|
||||
assert deps._is_private_client("::1") is True
|
||||
|
||||
def test_rfc1918_private_true(self):
|
||||
assert deps._is_private_client("10.0.0.1") is True
|
||||
assert deps._is_private_client("192.168.1.1") is True
|
||||
assert deps._is_private_client("172.16.0.1") is True
|
||||
assert deps._is_private_client("172.31.255.255") is True
|
||||
|
||||
def test_public_ip_false(self):
|
||||
assert deps._is_private_client("8.8.8.8") is False
|
||||
|
||||
def test_non_ip_false(self):
|
||||
assert deps._is_private_client("example.com") is False
|
||||
assert deps._is_private_client("") is False
|
||||
assert deps._is_private_client(None) is False
|
||||
|
||||
def test_172_non_private_octet_false(self):
|
||||
assert deps._is_private_client("172.15.0.1") is False
|
||||
assert deps._is_private_client("172.32.0.1") is False
|
||||
@@ -311,3 +311,47 @@ def test_calendar_ical_200_returns_only_that_users_duties(
|
||||
assert len(duties_arg) == 1
|
||||
assert duties_arg[0][0].user_id == 1
|
||||
assert duties_arg[0][1] == "User A"
|
||||
|
||||
|
||||
# --- /api/calendar-events ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.EXTERNAL_CALENDAR_ICS_URL", "")
|
||||
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_calendar_events_empty_url_returns_empty_list(client):
|
||||
"""When EXTERNAL_CALENDAR_ICS_URL is empty, GET /api/calendar-events returns [] without fetch."""
|
||||
with patch("duty_teller.api.app.get_calendar_events") as mock_get:
|
||||
r = client.get(
|
||||
"/api/calendar-events",
|
||||
params={"from": "2025-01-01", "to": "2025-01-31"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.EXTERNAL_CALENDAR_ICS_URL", "https://example.com/cal.ics")
|
||||
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_calendar_events_200_returns_list_with_date_summary(client):
|
||||
"""GET /api/calendar-events with auth and URL set returns list of {date, summary}."""
|
||||
with patch("duty_teller.api.app.get_calendar_events") as mock_get:
|
||||
mock_get.return_value = [
|
||||
{"date": "2025-01-15", "summary": "Team meeting"},
|
||||
{"date": "2025-01-20", "summary": "Review"},
|
||||
]
|
||||
r = client.get(
|
||||
"/api/calendar-events",
|
||||
params={"from": "2025-01-01", "to": "2025-01-31"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["date"] == "2025-01-15"
|
||||
assert data[0]["summary"] == "Team meeting"
|
||||
assert data[1]["date"] == "2025-01-20"
|
||||
assert data[1]["summary"] == "Review"
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/cal.ics",
|
||||
from_date="2025-01-01",
|
||||
to_date="2025-01-31",
|
||||
)
|
||||
|
||||
153
tests/test_calendar_ics.py
Normal file
153
tests/test_calendar_ics.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for duty_teller.api.calendar_ics (_to_date, _event_date_range, _get_events_from_ics, get_calendar_events)."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
from duty_teller.api import calendar_ics as mod
|
||||
|
||||
|
||||
class TestToDate:
|
||||
"""Tests for _to_date."""
|
||||
|
||||
def test_datetime_returns_date(self):
|
||||
dt = datetime(2025, 3, 15, 10, 0, 0)
|
||||
assert mod._to_date(dt) == date(2025, 3, 15)
|
||||
|
||||
def test_date_returns_same(self):
|
||||
d = date(2025, 3, 15)
|
||||
assert mod._to_date(d) == date(2025, 3, 15)
|
||||
|
||||
def test_invalid_returns_none(self):
|
||||
assert mod._to_date("2025-03-15") is None
|
||||
assert mod._to_date(None) is None
|
||||
assert mod._to_date(123) is None
|
||||
|
||||
|
||||
class TestEventDateRange:
|
||||
"""Tests for _event_date_range. DTEND is exclusive in iCalendar."""
|
||||
|
||||
def _vevent(self, dtstart_value, dtend_value=None):
|
||||
"""Build a minimal VEVENT component using icalendar.Event."""
|
||||
cal = Calendar()
|
||||
ev = Event()
|
||||
ev.add("dtstart", dtstart_value)
|
||||
if dtend_value is not None:
|
||||
ev.add("dtend", dtend_value)
|
||||
cal.add_component(ev)
|
||||
for c in cal.walk():
|
||||
if c.name == "VEVENT":
|
||||
return c
|
||||
raise AssertionError("no vevent")
|
||||
|
||||
def test_dtstart_and_dtend_exclusive_end(self):
|
||||
start = date(2025, 1, 10)
|
||||
end_exclusive = date(2025, 1, 13) # last day of event = 2025-01-12
|
||||
comp = self._vevent(start, end_exclusive)
|
||||
s, e = mod._event_date_range(comp)
|
||||
assert s == date(2025, 1, 10)
|
||||
assert e == date(2025, 1, 12)
|
||||
|
||||
def test_only_dtstart_returns_same_for_start_and_end(self):
|
||||
start = date(2025, 2, 1)
|
||||
comp = self._vevent(start)
|
||||
s, e = mod._event_date_range(comp)
|
||||
assert s == date(2025, 2, 1)
|
||||
assert e == date(2025, 2, 1)
|
||||
|
||||
def test_no_dtstart_returns_none_none(self):
|
||||
cal = Calendar()
|
||||
ev = Event()
|
||||
ev.add("summary", "No dates")
|
||||
cal.add_component(ev)
|
||||
for c in cal.walk():
|
||||
if c.name == "VEVENT":
|
||||
s, e = mod._event_date_range(c)
|
||||
assert s is None
|
||||
assert e is None
|
||||
return
|
||||
pytest.fail("no vevent")
|
||||
|
||||
|
||||
class TestGetEventsFromIcs:
|
||||
"""Tests for _get_events_from_ics."""
|
||||
|
||||
def test_minimal_ics_one_event(self):
|
||||
cal = Calendar()
|
||||
ev = Event()
|
||||
ev.add("dtstart", date(2025, 1, 15))
|
||||
ev.add("summary", "Meeting")
|
||||
cal.add_component(ev)
|
||||
raw = cal.to_ical()
|
||||
result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31")
|
||||
assert len(result) == 1
|
||||
assert result[0]["date"] == "2025-01-15"
|
||||
assert result[0]["summary"] == "Meeting"
|
||||
|
||||
def test_event_filtered_by_date_range(self):
|
||||
cal = Calendar()
|
||||
ev = Event()
|
||||
ev.add("dtstart", date(2025, 1, 10))
|
||||
ev.add("dtend", date(2025, 1, 14)) # 10,11,12,13
|
||||
ev.add("summary", "Multi")
|
||||
cal.add_component(ev)
|
||||
raw = cal.to_ical()
|
||||
result = mod._get_events_from_ics(raw, "2025-01-12", "2025-01-13")
|
||||
assert len(result) == 2
|
||||
assert result[0]["date"] == "2025-01-12"
|
||||
assert result[1]["date"] == "2025-01-13"
|
||||
|
||||
def test_empty_ics_returns_empty_list(self):
|
||||
cal = Calendar()
|
||||
raw = cal.to_ical()
|
||||
result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31")
|
||||
assert result == []
|
||||
|
||||
def test_broken_ics_returns_empty_list(self):
|
||||
result = mod._get_events_from_ics(b"not ical at all", "2025-01-01", "2025-01-31")
|
||||
assert result == []
|
||||
|
||||
def test_recurring_events_skipped(self):
|
||||
cal = Calendar()
|
||||
ev = Event()
|
||||
ev.add("dtstart", date(2025, 1, 15))
|
||||
ev.add("rrule", {"freq": "daily", "count": 3})
|
||||
ev.add("summary", "Recurring")
|
||||
cal.add_component(ev)
|
||||
raw = cal.to_ical()
|
||||
result = mod._get_events_from_ics(raw, "2025-01-01", "2025-01-31")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetCalendarEvents:
|
||||
"""Tests for get_calendar_events (with mocked _fetch_ics, no real HTTP)."""
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert mod.get_calendar_events("", "2025-01-01", "2025-01-31") == []
|
||||
|
||||
def test_from_after_to_returns_empty(self):
|
||||
assert mod.get_calendar_events("https://example.com/a.ics", "2025-02-01", "2025-01-01") == []
|
||||
|
||||
@patch.object(mod, "_fetch_ics", return_value=None)
|
||||
def test_fetch_returns_none_returns_empty(self, mock_fetch):
|
||||
result = mod.get_calendar_events("https://example.com/a.ics", "2025-01-01", "2025-01-31")
|
||||
assert result == []
|
||||
mock_fetch.assert_called_once()
|
||||
|
||||
@patch.object(mod, "_fetch_ics")
|
||||
def test_success_returns_events(self, mock_fetch):
|
||||
cal = Calendar()
|
||||
ev = Event()
|
||||
ev.add("dtstart", date(2025, 1, 20))
|
||||
ev.add("summary", "Test event")
|
||||
cal.add_component(ev)
|
||||
mock_fetch.return_value = cal.to_ical()
|
||||
result = mod.get_calendar_events(
|
||||
"https://example.com/cal.ics", "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["date"] == "2025-01-20"
|
||||
assert result[0]["summary"] == "Test event"
|
||||
mock_fetch.assert_called_once_with("https://example.com/cal.ics")
|
||||
@@ -25,6 +25,7 @@ def session():
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests for config.is_admin and config.can_access_miniapp."""
|
||||
"""Tests for config.is_admin, config.can_access_miniapp, and require_bot_token."""
|
||||
|
||||
import pytest
|
||||
|
||||
import duty_teller.config as config
|
||||
|
||||
@@ -67,3 +69,18 @@ def test_is_admin_by_phone(monkeypatch):
|
||||
assert config.is_admin_by_phone("+7 900 111-11-11") is True
|
||||
assert config.is_admin_by_phone("79001234567") is False
|
||||
assert config.is_admin_by_phone(None) is False
|
||||
|
||||
|
||||
def test_require_bot_token_raises_system_exit_when_empty(monkeypatch):
|
||||
"""require_bot_token() raises SystemExit with message about BOT_TOKEN when empty."""
|
||||
monkeypatch.setattr(config, "BOT_TOKEN", "")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
config.require_bot_token()
|
||||
assert "BOT_TOKEN" in str(exc_info.value)
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
def test_require_bot_token_does_not_raise_when_set(monkeypatch):
|
||||
"""require_bot_token() does nothing when BOT_TOKEN is set."""
|
||||
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
|
||||
config.require_bot_token()
|
||||
|
||||
39
tests/test_db_session.py
Normal file
39
tests/test_db_session.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Tests for duty_teller.db.session (session_scope rollback/close, get_engine)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.db import session as session_mod
|
||||
|
||||
|
||||
def test_session_scope_rollback_on_exception():
|
||||
"""session_scope: on exception inside block, rollback and close are called."""
|
||||
mock_session = MagicMock()
|
||||
with patch.object(session_mod, "get_session", return_value=mock_session):
|
||||
with pytest.raises(ValueError):
|
||||
with session_mod.session_scope("sqlite:///:memory:") as s:
|
||||
assert s is mock_session
|
||||
raise ValueError("test")
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_session_scope_close_on_success():
|
||||
"""session_scope: on normal exit, close is called (no rollback)."""
|
||||
mock_session = MagicMock()
|
||||
with patch.object(session_mod, "get_session", return_value=mock_session):
|
||||
with session_mod.session_scope("sqlite:///:memory:") as s:
|
||||
assert s is mock_session
|
||||
mock_session.rollback.assert_not_called()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_get_engine_same_url_returns_same_instance():
|
||||
"""get_engine: same URL returns the same engine instance."""
|
||||
session_mod._engine = None
|
||||
session_mod._SessionLocal = None
|
||||
url = "sqlite:///:memory:"
|
||||
e1 = session_mod.get_engine(url)
|
||||
e2 = session_mod.get_engine(url)
|
||||
assert e1 is e2
|
||||
196
tests/test_group_duty_pin_service.py
Normal file
196
tests/test_group_duty_pin_service.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Tests for duty_teller.services.group_duty_pin_service."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from duty_teller.db.models import Base, Duty, GroupDutyPin, User
|
||||
from duty_teller.services import group_duty_pin_service as svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
"""In-memory SQLite session with all models."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(session):
|
||||
"""Create a user in DB."""
|
||||
u = User(
|
||||
telegram_user_id=123,
|
||||
full_name="Test User",
|
||||
username="testuser",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
phone="+79001234567",
|
||||
)
|
||||
session.add(u)
|
||||
session.commit()
|
||||
session.refresh(u)
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def duty(session, user):
|
||||
"""Create a duty in DB (event_type='duty')."""
|
||||
d = Duty(
|
||||
user_id=user.id,
|
||||
start_at="2025-02-20T06:00:00Z",
|
||||
end_at="2025-02-21T06:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
session.add(d)
|
||||
session.commit()
|
||||
session.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
class TestFormatDutyMessage:
|
||||
"""Tests for format_duty_message."""
|
||||
|
||||
def test_none_duty_returns_no_duty(self):
|
||||
user = SimpleNamespace(full_name="U")
|
||||
with patch("duty_teller.services.group_duty_pin_service.t") as mock_t:
|
||||
mock_t.return_value = "No duty"
|
||||
result = svc.format_duty_message(None, user, "Europe/Moscow", "en")
|
||||
assert result == "No duty"
|
||||
mock_t.assert_called_with("en", "duty.no_duty")
|
||||
|
||||
def test_none_user_returns_no_duty(self):
|
||||
duty = SimpleNamespace(start_at="2025-01-15T09:00:00Z", end_at="2025-01-15T18:00:00Z")
|
||||
with patch("duty_teller.services.group_duty_pin_service.t") as mock_t:
|
||||
mock_t.return_value = "No duty"
|
||||
result = svc.format_duty_message(duty, None, "Europe/Moscow", "en")
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_duty_and_user_returns_formatted(self):
|
||||
duty = SimpleNamespace(
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
)
|
||||
user = SimpleNamespace(
|
||||
full_name="Иван Иванов",
|
||||
phone="+79001234567",
|
||||
username="ivan",
|
||||
)
|
||||
with patch("duty_teller.services.group_duty_pin_service.t") as mock_t:
|
||||
mock_t.side_effect = lambda lang, key: (
|
||||
"Duty" if key == "duty.label" else ""
|
||||
)
|
||||
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
|
||||
assert "Иван Иванов" in result
|
||||
assert "+79001234567" in result or "79001234567" in result
|
||||
assert "@ivan" in result
|
||||
assert "Duty" in result
|
||||
|
||||
|
||||
class TestGetDutyMessageText:
|
||||
"""Tests for get_duty_message_text."""
|
||||
|
||||
def test_no_current_duty_returns_no_duty(self, session):
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_current_duty",
|
||||
return_value=None,
|
||||
):
|
||||
with patch("duty_teller.services.group_duty_pin_service.t") as mock_t:
|
||||
mock_t.return_value = "No duty"
|
||||
result = svc.get_duty_message_text(session, "Europe/Moscow", "en")
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_current_duty_returns_formatted(self, session, duty, user):
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_current_duty",
|
||||
return_value=(duty, user),
|
||||
):
|
||||
with patch("duty_teller.services.group_duty_pin_service.t") as mock_t:
|
||||
mock_t.side_effect = lambda lang, key: (
|
||||
"Duty" if key == "duty.label" else "No duty"
|
||||
)
|
||||
result = svc.get_duty_message_text(session, "Europe/Moscow", "en")
|
||||
assert "Test User" in result
|
||||
assert "Duty" in result
|
||||
|
||||
|
||||
class TestGetNextShiftEndUtc:
|
||||
"""Tests for get_next_shift_end_utc."""
|
||||
|
||||
def test_no_next_shift_returns_none(self, session):
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_next_shift_end",
|
||||
return_value=None,
|
||||
):
|
||||
result = svc.get_next_shift_end_utc(session)
|
||||
assert result is None
|
||||
|
||||
def test_has_next_shift_returns_naive_utc(self, session):
|
||||
naive = datetime(2025, 2, 21, 6, 0, 0)
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_next_shift_end",
|
||||
return_value=naive,
|
||||
):
|
||||
result = svc.get_next_shift_end_utc(session)
|
||||
assert result == naive
|
||||
|
||||
|
||||
class TestSavePin:
|
||||
"""Tests for save_pin."""
|
||||
|
||||
def test_save_pin_creates_record(self, session):
|
||||
svc.save_pin(session, chat_id=100, message_id=42)
|
||||
mid = svc.get_message_id(session, 100)
|
||||
assert mid == 42
|
||||
|
||||
def test_save_pin_updates_existing(self, session):
|
||||
svc.save_pin(session, chat_id=100, message_id=42)
|
||||
svc.save_pin(session, chat_id=100, message_id=99)
|
||||
mid = svc.get_message_id(session, 100)
|
||||
assert mid == 99
|
||||
|
||||
|
||||
class TestDeletePin:
|
||||
"""Tests for delete_pin."""
|
||||
|
||||
def test_delete_pin_removes_record(self, session):
|
||||
svc.save_pin(session, chat_id=200, message_id=1)
|
||||
assert svc.get_message_id(session, 200) == 1
|
||||
svc.delete_pin(session, chat_id=200)
|
||||
assert svc.get_message_id(session, 200) is None
|
||||
|
||||
|
||||
class TestGetMessageId:
|
||||
"""Tests for get_message_id."""
|
||||
|
||||
def test_no_pin_returns_none(self, session):
|
||||
assert svc.get_message_id(session, 999) is None
|
||||
|
||||
def test_after_save_returns_message_id(self, session):
|
||||
svc.save_pin(session, chat_id=300, message_id=77)
|
||||
assert svc.get_message_id(session, 300) == 77
|
||||
|
||||
|
||||
class TestGetAllPinChatIds:
|
||||
"""Tests for get_all_pin_chat_ids."""
|
||||
|
||||
def test_empty_returns_empty_list(self, session):
|
||||
assert svc.get_all_pin_chat_ids(session) == []
|
||||
|
||||
def test_returns_all_chat_ids_with_pins(self, session):
|
||||
svc.save_pin(session, chat_id=10, message_id=1)
|
||||
svc.save_pin(session, chat_id=20, message_id=2)
|
||||
chat_ids = svc.get_all_pin_chat_ids(session)
|
||||
assert set(chat_ids) == {10, 20}
|
||||
249
tests/test_handlers_commands.py
Normal file
249
tests/test_handlers_commands.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Tests for duty_teller.handlers.commands (start, set_phone, calendar_link, help_cmd)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.handlers.commands import (
|
||||
start,
|
||||
set_phone,
|
||||
calendar_link,
|
||||
help_cmd,
|
||||
)
|
||||
|
||||
|
||||
def _make_update(message=None, effective_user=None, effective_chat=None):
|
||||
"""Build a minimal Update with message and effective_user/chat."""
|
||||
update = MagicMock()
|
||||
update.message = message
|
||||
update.effective_user = effective_user
|
||||
update.effective_chat = effective_chat
|
||||
return update
|
||||
|
||||
|
||||
def _make_user(
|
||||
user_id=1,
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
username="testuser",
|
||||
):
|
||||
"""Build a minimal Telegram user."""
|
||||
u = MagicMock()
|
||||
u.id = user_id
|
||||
u.first_name = first_name
|
||||
u.last_name = last_name
|
||||
u.username = username
|
||||
return u
|
||||
|
||||
|
||||
def _make_chat(chat_type="private"):
|
||||
"""Build a minimal chat."""
|
||||
ch = MagicMock()
|
||||
ch.type = chat_type
|
||||
return ch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_calls_get_or_create_user_and_reply_text():
|
||||
"""start: calls get_or_create_user with expected fields and reply_text with start.greeting."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user(42, first_name="Alice", last_name="B")
|
||||
update = _make_update(message=message, effective_user=user)
|
||||
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.session_scope",
|
||||
) as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.get_or_create_user",
|
||||
) as mock_get_user:
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.return_value = "Hi! Use /help."
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
await start(update, MagicMock())
|
||||
mock_get_user.assert_called_once()
|
||||
call_kw = mock_get_user.call_args[1]
|
||||
assert call_kw["telegram_user_id"] == 42
|
||||
assert call_kw["full_name"] == "Alice B"
|
||||
assert call_kw["username"] == "testuser"
|
||||
message.reply_text.assert_called_once_with("Hi! Use /help.")
|
||||
mock_t.assert_called_with("en", "start.greeting")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_no_message_returns_early():
|
||||
"""start: if update.message is None, does not call get_or_create_user or reply."""
|
||||
update = _make_update(message=None, effective_user=_make_user())
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
with patch("duty_teller.handlers.commands.get_or_create_user") as mock_get_user:
|
||||
await start(update, MagicMock())
|
||||
mock_get_user.assert_not_called()
|
||||
mock_scope.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_phone_private_with_number_replies_saved():
|
||||
"""set_phone in private chat with number -> reply 'saved'."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user()
|
||||
chat = _make_chat("private")
|
||||
update = _make_update(message=message, effective_user=user, effective_chat=chat)
|
||||
context = MagicMock()
|
||||
context.args = ["+79001234567"]
|
||||
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch("duty_teller.handlers.commands.get_or_create_user"):
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.set_user_phone",
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.return_value = "Phone saved: +79001234567"
|
||||
await set_phone(update, context)
|
||||
message.reply_text.assert_called_once_with("Phone saved: +79001234567")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_phone_private_no_number_replies_cleared():
|
||||
"""set_phone in private chat without number -> reply 'cleared'."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user()
|
||||
chat = _make_chat("private")
|
||||
update = _make_update(message=message, effective_user=user, effective_chat=chat)
|
||||
context = MagicMock()
|
||||
context.args = []
|
||||
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch("duty_teller.handlers.commands.get_or_create_user"):
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.set_user_phone",
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.return_value = "Phone cleared"
|
||||
await set_phone(update, context)
|
||||
message.reply_text.assert_called_once_with("Phone cleared")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_phone_group_replies_private_only():
|
||||
"""set_phone in group chat -> reply private_only."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user()
|
||||
chat = _make_chat("group")
|
||||
update = _make_update(message=message, effective_user=user, effective_chat=chat)
|
||||
context = MagicMock()
|
||||
context.args = ["+79001234567"]
|
||||
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.return_value = "Private only"
|
||||
await set_phone(update, context)
|
||||
message.reply_text.assert_called_once_with("Private only")
|
||||
mock_t.assert_called_with("en", "set_phone.private_only")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_link_with_user_and_token_replies_with_url():
|
||||
"""calendar_link with allowed user and token -> reply with link."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user()
|
||||
chat = _make_chat("private")
|
||||
update = _make_update(message=message, effective_user=user, effective_chat=chat)
|
||||
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch("duty_teller.handlers.commands.get_or_create_user") as mock_get_user:
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = 10
|
||||
mock_user.phone = None
|
||||
mock_get_user.return_value = mock_user
|
||||
with patch("duty_teller.handlers.commands.config") as mock_cfg:
|
||||
mock_cfg.can_access_miniapp.return_value = True
|
||||
mock_cfg.can_access_miniapp_by_phone.return_value = False
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com"
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.create_calendar_token",
|
||||
return_value="abc43token",
|
||||
):
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.side_effect = lambda lang, key, **kw: (
|
||||
f"URL: {kw.get('url', '')}" if "success" in key else "Hint"
|
||||
)
|
||||
await calendar_link(update, MagicMock())
|
||||
message.reply_text.assert_called_once()
|
||||
call_args = message.reply_text.call_args[0][0]
|
||||
assert "abc43token" in call_args or "example.com" in call_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_link_denied_replies_access_denied():
|
||||
"""calendar_link when user not in allowlist -> reply access_denied."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user()
|
||||
chat = _make_chat("private")
|
||||
update = _make_update(message=message, effective_user=user, effective_chat=chat)
|
||||
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch("duty_teller.handlers.commands.get_or_create_user") as mock_get_user:
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = 10
|
||||
mock_user.phone = None
|
||||
mock_get_user.return_value = mock_user
|
||||
with patch("duty_teller.handlers.commands.config") as mock_cfg:
|
||||
mock_cfg.can_access_miniapp.return_value = False
|
||||
mock_cfg.can_access_miniapp_by_phone.return_value = False
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.return_value = "Access denied"
|
||||
await calendar_link(update, MagicMock())
|
||||
message.reply_text.assert_called_once_with("Access denied")
|
||||
mock_t.assert_called_with("en", "calendar_link.access_denied")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_cmd_replies_with_help_text():
|
||||
"""help_cmd: reply_text called with help content (title, start, help, etc.)."""
|
||||
message = MagicMock()
|
||||
message.reply_text = AsyncMock()
|
||||
user = _make_user()
|
||||
update = _make_update(message=message, effective_user=user)
|
||||
|
||||
with patch("duty_teller.handlers.commands.session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch(
|
||||
"duty_teller.handlers.commands.is_admin_for_telegram_user",
|
||||
return_value=False,
|
||||
):
|
||||
with patch("duty_teller.handlers.commands.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.commands.t") as mock_t:
|
||||
mock_t.side_effect = lambda lang, key: f"[{key}]"
|
||||
await help_cmd(update, MagicMock())
|
||||
message.reply_text.assert_called_once()
|
||||
text = message.reply_text.call_args[0][0]
|
||||
assert "help.title" in text or "[help.title]" in text
|
||||
assert "help.start" in text or "[help.start]" in text
|
||||
53
tests/test_handlers_errors.py
Normal file
53
tests/test_handlers_errors.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for duty_teller.handlers.errors (error_handler)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.handlers.errors import error_handler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handler_replies_with_generic_message():
|
||||
"""error_handler: when update has effective_message, reply_text with errors.generic."""
|
||||
# Handler checks isinstance(update, Update); patch Update so our mock passes.
|
||||
class FakeUpdate:
|
||||
pass
|
||||
|
||||
update = FakeUpdate()
|
||||
update.effective_message = MagicMock()
|
||||
update.effective_message.reply_text = AsyncMock()
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.error = Exception("test error")
|
||||
|
||||
with patch("duty_teller.handlers.errors.Update", FakeUpdate):
|
||||
with patch("duty_teller.handlers.errors.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.errors.t") as mock_t:
|
||||
mock_t.return_value = "An error occurred."
|
||||
await error_handler(update, context)
|
||||
update.effective_message.reply_text.assert_called_once_with("An error occurred.")
|
||||
mock_t.assert_called_with("en", "errors.generic")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handler_no_effective_message_does_not_send():
|
||||
"""error_handler: when update has no effective_message, does not call reply_text."""
|
||||
update = MagicMock()
|
||||
update.effective_message = None
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.error = Exception("test")
|
||||
|
||||
with patch("duty_teller.handlers.errors.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.errors.t") as mock_t:
|
||||
await error_handler(update, context)
|
||||
mock_t.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handler_update_none_does_not_crash():
|
||||
"""error_handler: when update is None, does not crash (no reply)."""
|
||||
context = MagicMock()
|
||||
context.error = Exception("test")
|
||||
await error_handler(None, context)
|
||||
145
tests/test_handlers_group_duty_pin.py
Normal file
145
tests/test_handlers_group_duty_pin.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Tests for duty_teller.handlers.group_duty_pin (sync wrappers, update_group_pin, pin_duty_cmd)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.handlers import group_duty_pin as mod
|
||||
|
||||
|
||||
class TestSyncWrappers:
|
||||
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
|
||||
|
||||
def test_get_duty_message_text_sync(self):
|
||||
with patch.object(mod, "session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch.object(mod, "get_duty_message_text", return_value="Duty text"):
|
||||
result = mod._get_duty_message_text_sync("en")
|
||||
assert result == "Duty text"
|
||||
mock_scope.assert_called_once()
|
||||
|
||||
def test_sync_save_pin(self):
|
||||
with patch.object(mod, "session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch.object(mod, "save_pin") as mock_save:
|
||||
mod._sync_save_pin(100, 42)
|
||||
mock_save.assert_called_once_with(mock_session, 100, 42)
|
||||
|
||||
def test_sync_delete_pin(self):
|
||||
with patch.object(mod, "session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch.object(mod, "delete_pin") as mock_delete:
|
||||
mod._sync_delete_pin(200)
|
||||
mock_delete.assert_called_once_with(mock_session, 200)
|
||||
|
||||
def test_sync_get_message_id(self):
|
||||
with patch.object(mod, "session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch.object(mod, "get_message_id", return_value=99):
|
||||
result = mod._sync_get_message_id(300)
|
||||
assert result == 99
|
||||
|
||||
def test_get_all_pin_chat_ids_sync(self):
|
||||
with patch.object(mod, "session_scope") as mock_scope:
|
||||
mock_session = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
with patch.object(mod, "get_all_pin_chat_ids", return_value=[10, 20]):
|
||||
result = mod._get_all_pin_chat_ids_sync()
|
||||
assert result == [10, 20]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_edits_message_and_schedules_next():
|
||||
"""update_group_pin: with message_id and text, edits message and schedules next update."""
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 123}
|
||||
context.bot = MagicMock()
|
||||
context.bot.edit_message_text = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
mod, "_sync_get_message_id", return_value=1
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Current duty"
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.edit_message_text.assert_called_once_with(
|
||||
chat_id=123, message_id=1, text="Current duty"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_no_message_id_skips():
|
||||
"""update_group_pin: when no pin record (message_id None), does not edit."""
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 456}
|
||||
context.bot = MagicMock()
|
||||
context.bot.edit_message_text = AsyncMock()
|
||||
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.edit_message_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_group_only_reply():
|
||||
"""pin_duty_cmd in private chat -> reply group_only."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "private"
|
||||
update.effective_chat.id = 1
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Group only"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Group only")
|
||||
mock_t.assert_called_with("en", "pin_duty.group_only")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
"""pin_duty_cmd in group with existing pin record -> pin and reply pinned."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=5, disable_notification=True
|
||||
)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
13
tests/test_handlers_init.py
Normal file
13
tests/test_handlers_init.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Tests for duty_teller.handlers.register_handlers."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from duty_teller.handlers import register_handlers
|
||||
|
||||
|
||||
def test_register_handlers_adds_all_handlers():
|
||||
"""register_handlers: adds command, import, group pin handlers and error handler."""
|
||||
mock_app = MagicMock()
|
||||
register_handlers(mock_app)
|
||||
assert mock_app.add_handler.call_count >= 9
|
||||
assert mock_app.add_error_handler.call_count == 1
|
||||
@@ -20,17 +20,23 @@ def db_url():
|
||||
return "sqlite:///:memory:"
|
||||
|
||||
|
||||
def _dispose_global_engine(session_module):
|
||||
"""Dispose global engine so connections are closed, then clear cache."""
|
||||
if session_module._engine is not None:
|
||||
session_module._engine.dispose()
|
||||
session_module._engine = None
|
||||
session_module._SessionLocal = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_db_session(db_url):
|
||||
"""Ensure each test uses a fresh engine for :memory: (clear global cache for test URL)."""
|
||||
import duty_teller.db.session as session_module
|
||||
|
||||
session_module._engine = None
|
||||
session_module._SessionLocal = None
|
||||
_dispose_global_engine(session_module)
|
||||
init_db(db_url)
|
||||
yield
|
||||
session_module._engine = None
|
||||
session_module._SessionLocal = None
|
||||
_dispose_global_engine(session_module)
|
||||
|
||||
|
||||
def test_import_creates_users_and_duties(db_url):
|
||||
|
||||
47
tests/test_import_service.py
Normal file
47
tests/test_import_service.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for duty_teller.services.import_service (_consecutive_date_ranges)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.services.import_service import _consecutive_date_ranges
|
||||
|
||||
|
||||
class TestConsecutiveDateRanges:
|
||||
"""Unit tests for _consecutive_date_ranges."""
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
assert _consecutive_date_ranges([]) == []
|
||||
|
||||
def test_single_day_one_range(self):
|
||||
d = date(2025, 2, 10)
|
||||
assert _consecutive_date_ranges([d]) == [(d, d)]
|
||||
|
||||
def test_consecutive_days_one_range(self):
|
||||
dates = [date(2025, 1, 10), date(2025, 1, 11), date(2025, 1, 12)]
|
||||
assert _consecutive_date_ranges(dates) == [
|
||||
(date(2025, 1, 10), date(2025, 1, 12)),
|
||||
]
|
||||
|
||||
def test_two_ranges_with_gap(self):
|
||||
dates = [
|
||||
date(2025, 1, 5),
|
||||
date(2025, 1, 6),
|
||||
date(2025, 1, 10),
|
||||
date(2025, 1, 11),
|
||||
]
|
||||
assert _consecutive_date_ranges(dates) == [
|
||||
(date(2025, 1, 5), date(2025, 1, 6)),
|
||||
(date(2025, 1, 10), date(2025, 1, 11)),
|
||||
]
|
||||
|
||||
def test_unsorted_with_duplicates_normalized(self):
|
||||
dates = [
|
||||
date(2025, 1, 12),
|
||||
date(2025, 1, 10),
|
||||
date(2025, 1, 11),
|
||||
date(2025, 1, 10),
|
||||
]
|
||||
assert _consecutive_date_ranges(dates) == [
|
||||
(date(2025, 1, 10), date(2025, 1, 12)),
|
||||
]
|
||||
16
tests/test_package_init.py
Normal file
16
tests/test_package_init.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Tests for duty_teller package __init__ (version fallback)."""
|
||||
|
||||
import importlib
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from unittest.mock import patch
|
||||
|
||||
import duty_teller
|
||||
|
||||
|
||||
def test_version_fallback_when_package_not_installed():
|
||||
"""When version('duty-teller') raises PackageNotFoundError, __version__ is '0.1.0'."""
|
||||
with patch("importlib.metadata.version", side_effect=PackageNotFoundError):
|
||||
importlib.reload(duty_teller)
|
||||
assert duty_teller.__version__ == "0.1.0"
|
||||
# Restore so other tests see normal version
|
||||
importlib.reload(duty_teller)
|
||||
@@ -27,6 +27,7 @@ def session():
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
119
tests/test_run.py
Normal file
119
tests/test_run.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for duty_teller.run (main entry point with mocks)."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.run import main, _run_uvicorn, _set_default_menu_button_webapp
|
||||
|
||||
|
||||
def test_main_builds_app_and_starts_thread():
|
||||
"""main: calls require_bot_token, builds Application, register_handlers, starts uvicorn thread, run_polling."""
|
||||
mock_app = MagicMock()
|
||||
mock_app.run_polling = MagicMock(side_effect=KeyboardInterrupt())
|
||||
mock_builder = MagicMock()
|
||||
mock_builder.token.return_value = mock_builder
|
||||
mock_builder.post_init.return_value = mock_builder
|
||||
mock_builder.build.return_value = mock_app
|
||||
|
||||
# Avoid loading real DB when main() imports api.app (prevents unclosed connection warnings).
|
||||
mock_session = MagicMock()
|
||||
mock_scope = MagicMock()
|
||||
mock_scope.return_value.__enter__.return_value = mock_session
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
|
||||
with patch("duty_teller.run.require_bot_token"):
|
||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||
with patch("duty_teller.db.session.session_scope", mock_scope):
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_class.return_value = mock_thread
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
main()
|
||||
mock_register.assert_called_once_with(mock_app)
|
||||
mock_builder.token.assert_called_once()
|
||||
mock_thread.start.assert_called_once()
|
||||
mock_app.run_polling.assert_called_once()
|
||||
|
||||
|
||||
def test_run_uvicorn_creates_server():
|
||||
"""_run_uvicorn: creates uvicorn config and runs server (mocked)."""
|
||||
with patch("uvicorn.Server") as mock_server_class:
|
||||
mock_server = MagicMock()
|
||||
# run_until_complete needs a real coroutine
|
||||
mock_server.serve = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_server_class.return_value = mock_server
|
||||
_run_uvicorn(MagicMock(), 8080)
|
||||
mock_server_class.assert_called_once()
|
||||
mock_server.serve.assert_called_once()
|
||||
|
||||
|
||||
def test_set_default_menu_button_skips_when_no_base_url():
|
||||
"""_set_default_menu_button_webapp: returns early when MINI_APP_BASE_URL or BOT_TOKEN not set."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = ""
|
||||
mock_cfg.BOT_TOKEN = "token"
|
||||
_set_default_menu_button_webapp()
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com"
|
||||
mock_cfg.BOT_TOKEN = ""
|
||||
_set_default_menu_button_webapp()
|
||||
# No urlopen should be called
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = None
|
||||
mock_cfg.BOT_TOKEN = "x"
|
||||
_set_default_menu_button_webapp()
|
||||
|
||||
|
||||
def test_set_default_menu_button_skips_when_not_https():
|
||||
"""_set_default_menu_button_webapp: returns early when menu_url does not start with https."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "http://example.com"
|
||||
mock_cfg.BOT_TOKEN = "token"
|
||||
_set_default_menu_button_webapp()
|
||||
# No urlopen
|
||||
|
||||
|
||||
def test_set_default_menu_button_calls_telegram_api():
|
||||
"""_set_default_menu_button_webapp: when URL is https, calls Telegram API (mocked)."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com/"
|
||||
mock_cfg.BOT_TOKEN = "123:ABC"
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
_set_default_menu_button_webapp()
|
||||
mock_urlopen.assert_called_once()
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert "setChatMenuButton" in req.full_url
|
||||
assert "123:ABC" in req.full_url
|
||||
|
||||
|
||||
def test_set_default_menu_button_handles_urlopen_error():
|
||||
"""_set_default_menu_button_webapp: on urlopen error, logs and does not raise."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com/"
|
||||
mock_cfg.BOT_TOKEN = "123:ABC"
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.side_effect = OSError("network error")
|
||||
_set_default_menu_button_webapp()
|
||||
mock_urlopen.assert_called_once()
|
||||
|
||||
|
||||
def test_set_default_menu_button_handles_non_200_response():
|
||||
"""_set_default_menu_button_webapp: on non-200 response, logs warning."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com/"
|
||||
mock_cfg.BOT_TOKEN = "123:ABC"
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 400
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
_set_default_menu_button_webapp()
|
||||
mock_urlopen.assert_called_once()
|
||||
@@ -97,3 +97,8 @@ def test_parse_handover_invalid():
|
||||
assert parse_handover_time("not a time") is None
|
||||
# 25:00 is normalized to 1:00 by hour % 24; use non-matching string
|
||||
assert parse_handover_time("12") is None
|
||||
|
||||
|
||||
def test_parse_handover_invalid_timezone_returns_none():
|
||||
"""Invalid IANA timezone string -> ZoneInfo raises, returns None."""
|
||||
assert parse_handover_time("09:00 NotAReal/Timezone") is None
|
||||
|
||||
Reference in New Issue
Block a user