Some checks failed
CI / lint-and-test (push) Failing after 27s
- Added a global exception handler to log unhandled exceptions and return a generic 500 JSON response without exposing details to the client. - Updated the configuration to validate the `DATABASE_URL` format, ensuring it starts with `sqlite://` or `postgresql://`, and log warnings for invalid formats. - Introduced safe parsing for numeric environment variables (`HTTP_PORT`, `INIT_DATA_MAX_AGE_SECONDS`) with defaults on invalid values, including logging warnings for out-of-range values. - Enhanced the duty schedule parser to enforce limits on the number of schedule rows and the length of full names and duty strings, raising appropriate errors when exceeded. - Updated internationalization messages to include generic error responses for import failures and parsing issues, improving user experience. - Added unit tests to verify the new error handling and configuration validation behaviors.
395 lines
16 KiB
Python
395 lines
16 KiB
Python
"""Tests for duty_teller.handlers.import_duty_schedule (import_duty_schedule_cmd, handover_time, document)."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from duty_teller.handlers import import_duty_schedule as mod
|
|
from duty_teller.importers.duty_schedule import (
|
|
DutyScheduleParseError,
|
|
DutyScheduleResult,
|
|
)
|
|
from datetime import date
|
|
|
|
|
|
def _make_update(message=None, effective_user=None):
|
|
"""Build minimal Update with message and effective_user."""
|
|
update = MagicMock()
|
|
update.message = message
|
|
update.effective_user = effective_user
|
|
return update
|
|
|
|
|
|
def _make_user(user_id=1, first_name="Admin", last_name="User", username="admin"):
|
|
"""Build minimal Telegram user."""
|
|
u = MagicMock()
|
|
u.id = user_id
|
|
u.first_name = first_name
|
|
u.last_name = last_name
|
|
u.username = username
|
|
return u
|
|
|
|
|
|
# --- import_duty_schedule_cmd ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_duty_schedule_cmd_no_message_returns_early():
|
|
"""import_duty_schedule_cmd: no update.message -> no reply, no user_data."""
|
|
update = _make_update(message=None, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {}
|
|
await mod.import_duty_schedule_cmd(update, context)
|
|
assert not context.user_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_duty_schedule_cmd_no_effective_user_returns_early():
|
|
"""import_duty_schedule_cmd: no effective_user -> no reply, no user_data."""
|
|
message = MagicMock()
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=None)
|
|
context = MagicMock()
|
|
context.user_data = {}
|
|
await mod.import_duty_schedule_cmd(update, context)
|
|
assert not context.user_data
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_duty_schedule_cmd_not_admin_replies_admin_only():
|
|
"""import_duty_schedule_cmd: non-admin -> reply_text(import.admin_only)."""
|
|
message = MagicMock()
|
|
message.reply_text = AsyncMock()
|
|
user = _make_user(42)
|
|
update = _make_update(message=message, effective_user=user)
|
|
context = MagicMock()
|
|
context.user_data = {}
|
|
|
|
with patch.object(
|
|
mod, "is_admin_async", new_callable=AsyncMock, return_value=False
|
|
):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "Admin only"
|
|
await mod.import_duty_schedule_cmd(update, context)
|
|
message.reply_text.assert_called_once_with("Admin only")
|
|
mock_t.assert_called_with("en", "import.admin_only")
|
|
assert not context.user_data.get("awaiting_handover_time")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_duty_schedule_cmd_admin_sets_user_data_and_replies_handover_format():
|
|
"""import_duty_schedule_cmd: admin -> user_data awaiting_handover_time=True, reply handover_format."""
|
|
message = MagicMock()
|
|
message.reply_text = AsyncMock()
|
|
user = _make_user(42)
|
|
update = _make_update(message=message, effective_user=user)
|
|
context = MagicMock()
|
|
context.user_data = {}
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "Enter time e.g. 09:00 Europe/Moscow"
|
|
await mod.import_duty_schedule_cmd(update, context)
|
|
assert context.user_data["awaiting_handover_time"] is True
|
|
message.reply_text.assert_called_once_with("Enter time e.g. 09:00 Europe/Moscow")
|
|
mock_t.assert_called_with("en", "import.handover_format")
|
|
|
|
|
|
# --- handle_handover_time_text ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_handover_time_text_no_message_returns_early():
|
|
"""handle_handover_time_text: no message -> no reply."""
|
|
update = _make_update(message=None, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {"awaiting_handover_time": True}
|
|
await mod.handle_handover_time_text(update, context)
|
|
# Handler returns early; update.message is None so we only check user_data unchanged.
|
|
assert context.user_data["awaiting_handover_time"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_handover_time_text_no_awaiting_flag_returns_early():
|
|
"""handle_handover_time_text: not awaiting_handover_time -> no reply."""
|
|
message = MagicMock()
|
|
message.text = "09:00 Europe/Moscow"
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {}
|
|
with patch.object(mod, "parse_handover_time"):
|
|
await mod.handle_handover_time_text(update, context)
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_handover_time_text_not_admin_returns_early():
|
|
"""handle_handover_time_text: not admin -> no reply."""
|
|
message = MagicMock()
|
|
message.text = "09:00 Europe/Moscow"
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {"awaiting_handover_time": True}
|
|
with patch.object(
|
|
mod, "is_admin_async", new_callable=AsyncMock, return_value=False
|
|
):
|
|
with patch.object(mod, "parse_handover_time"):
|
|
await mod.handle_handover_time_text(update, context)
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_handover_time_text_parse_error_replies_parse_time_error():
|
|
"""handle_handover_time_text: parse_handover_time returns None -> reply parse_time_error."""
|
|
message = MagicMock()
|
|
message.text = "invalid"
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {"awaiting_handover_time": True}
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "parse_handover_time", return_value=None):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "Could not parse time"
|
|
await mod.handle_handover_time_text(update, context)
|
|
message.reply_text.assert_called_once_with("Could not parse time")
|
|
mock_t.assert_called_with("en", "import.parse_time_error")
|
|
assert context.user_data.get("awaiting_handover_time") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_handover_time_text_success_sets_user_data_and_replies_send_json():
|
|
"""handle_handover_time_text: parsed time -> user_data updated, reply send_json."""
|
|
message = MagicMock()
|
|
message.text = " 09:00 Europe/Moscow "
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {"awaiting_handover_time": True}
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "parse_handover_time", return_value=(6, 0)):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "Send JSON file"
|
|
await mod.handle_handover_time_text(update, context)
|
|
assert context.user_data["handover_utc_time"] == (6, 0)
|
|
assert context.user_data["awaiting_handover_time"] is False
|
|
assert context.user_data["awaiting_duty_schedule_file"] is True
|
|
message.reply_text.assert_called_once_with("Send JSON file")
|
|
mock_t.assert_called_with("en", "import.send_json")
|
|
|
|
|
|
# --- handle_duty_schedule_document ---
|
|
|
|
|
|
def _make_document(file_id="file-123", file_name="schedule.json"):
|
|
"""Build minimal Document with file_id and file_name."""
|
|
doc = MagicMock()
|
|
doc.file_id = file_id
|
|
doc.file_name = file_name
|
|
return doc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_no_document_returns_early():
|
|
"""handle_duty_schedule_document: no document -> no import."""
|
|
message = MagicMock()
|
|
message.document = None
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {
|
|
"awaiting_duty_schedule_file": True,
|
|
"handover_utc_time": (6, 0),
|
|
}
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_not_awaiting_file_returns_early():
|
|
"""handle_duty_schedule_document: not awaiting_duty_schedule_file -> no import."""
|
|
message = MagicMock()
|
|
message.document = _make_document()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {}
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_no_handover_time_returns_early():
|
|
"""handle_duty_schedule_document: no handover_utc_time -> no import."""
|
|
message = MagicMock()
|
|
message.document = _make_document()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {"awaiting_duty_schedule_file": True}
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_not_admin_returns_early():
|
|
"""handle_duty_schedule_document: not admin -> no import."""
|
|
message = MagicMock()
|
|
message.document = _make_document()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {
|
|
"awaiting_duty_schedule_file": True,
|
|
"handover_utc_time": (6, 0),
|
|
}
|
|
with patch.object(
|
|
mod, "is_admin_async", new_callable=AsyncMock, return_value=False
|
|
):
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_non_json_replies_need_json():
|
|
"""handle_duty_schedule_document: file not .json -> reply need_json."""
|
|
message = MagicMock()
|
|
message.document = _make_document(file_name="data.txt")
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {
|
|
"awaiting_duty_schedule_file": True,
|
|
"handover_utc_time": (6, 0),
|
|
}
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "File must be .json"
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_called_once_with("File must be .json")
|
|
mock_t.assert_called_with("en", "import.need_json")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_parse_error_replies_generic_and_clears_user_data():
|
|
"""handle_duty_schedule_document: DutyScheduleParseError -> reply generic message (no str(e)), clear user_data."""
|
|
message = MagicMock()
|
|
message.document = _make_document()
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {
|
|
"awaiting_duty_schedule_file": True,
|
|
"handover_utc_time": (6, 0),
|
|
}
|
|
context.bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.download_as_bytearray = AsyncMock(return_value=bytearray(b"invalid"))
|
|
context.bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "parse_duty_schedule") as mock_parse:
|
|
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "The file could not be parsed."
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_called_once_with("The file could not be parsed.")
|
|
mock_t.assert_called_with("en", "import.parse_error_generic")
|
|
assert "awaiting_duty_schedule_file" not in context.user_data
|
|
assert "handover_utc_time" not in context.user_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_import_error_replies_generic_and_clears_user_data():
|
|
"""handle_duty_schedule_document: run_import raises -> reply generic message (no str(e)), clear user_data."""
|
|
message = MagicMock()
|
|
message.document = _make_document()
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {
|
|
"awaiting_duty_schedule_file": True,
|
|
"handover_utc_time": (6, 0),
|
|
}
|
|
mock_file = MagicMock()
|
|
mock_file.download_as_bytearray = AsyncMock(return_value=bytearray(b"{}"))
|
|
context.bot = MagicMock()
|
|
context.bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = DutyScheduleResult(
|
|
start_date=date(2026, 2, 1),
|
|
end_date=date(2026, 2, 28),
|
|
entries=[],
|
|
)
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "parse_duty_schedule", return_value=result):
|
|
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, "run_import") as mock_run:
|
|
mock_run.side_effect = ValueError("DB error")
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.return_value = "Import failed. Please try again."
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_called_once_with("Import failed. Please try again.")
|
|
mock_t.assert_called_with("en", "import.import_error_generic")
|
|
assert "awaiting_duty_schedule_file" not in context.user_data
|
|
assert "handover_utc_time" not in context.user_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_duty_schedule_document_success_replies_done_and_clears_user_data():
|
|
"""handle_duty_schedule_document: successful import -> reply done, clear user_data."""
|
|
message = MagicMock()
|
|
message.document = _make_document()
|
|
message.reply_text = AsyncMock()
|
|
update = _make_update(message=message, effective_user=_make_user())
|
|
context = MagicMock()
|
|
context.user_data = {
|
|
"awaiting_duty_schedule_file": True,
|
|
"handover_utc_time": (6, 0),
|
|
}
|
|
mock_file = MagicMock()
|
|
mock_file.download_as_bytearray = AsyncMock(return_value=bytearray(b"{}"))
|
|
context.bot = MagicMock()
|
|
context.bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = DutyScheduleResult(
|
|
start_date=date(2026, 2, 1),
|
|
end_date=date(2026, 2, 28),
|
|
entries=[],
|
|
)
|
|
|
|
with patch.object(mod, "is_admin_async", new_callable=AsyncMock, return_value=True):
|
|
with patch.object(mod, "get_lang", return_value="en"):
|
|
with patch.object(mod, "parse_duty_schedule", return_value=result):
|
|
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, "run_import", return_value=(3, 5, 0, 1)):
|
|
with patch.object(mod, "t") as mock_t:
|
|
mock_t.side_effect = lambda lang, key, **kw: (
|
|
"Done: 3 users, 5 duties, 1 vacation (6 total)"
|
|
if key == "import.done"
|
|
else ""
|
|
)
|
|
await mod.handle_duty_schedule_document(update, context)
|
|
message.reply_text.assert_called_once()
|
|
assert "done" in message.reply_text.call_args[0][0].lower() or "import.done" in str(
|
|
message.reply_text.call_args
|
|
)
|
|
assert "awaiting_duty_schedule_file" not in context.user_data
|
|
assert "handover_utc_time" not in context.user_data
|