chore: update development dependencies and improve test coverage
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:
2026-02-20 17:33:04 +03:00
parent ae21883e1e
commit e25eb7be2f
24 changed files with 1267 additions and 18 deletions

22
tests/conftest.py Normal file
View 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

View 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

View File

@@ -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
View 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")

View File

@@ -25,6 +25,7 @@ def session():
yield s
finally:
s.close()
engine.dispose()
@pytest.fixture

View File

@@ -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
View 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

View 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}

View 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

View 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)

View 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")

View 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

View File

@@ -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):

View 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)),
]

View 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)

View File

@@ -27,6 +27,7 @@ def session():
yield s
finally:
s.close()
engine.dispose()
@pytest.fixture

119
tests/test_run.py Normal file
View 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()

View File

@@ -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