diff --git a/.coverage b/.coverage index 179c1d7..e697fe7 100644 Binary files a/.coverage and b/.coverage differ diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index 97b62d5..857ffcc 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -10,15 +10,32 @@ from sqlalchemy.orm import Session import duty_teller.config as config from duty_teller.api.telegram_auth import validate_init_data_with_reason from duty_teller.db.repository import get_duties, get_user_by_telegram_id -from duty_teller.db.schemas import DutyWithUser +from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser from duty_teller.db.session import session_scope from duty_teller.i18n import t -from duty_teller.utils.dates import validate_date_range +from duty_teller.i18n.lang import normalize_lang +from duty_teller.utils.dates import DateRangeValidationError, validate_date_range log = logging.getLogger(__name__) -# First language tag from Accept-Language (e.g. "ru-RU,ru;q=0.9,en;q=0.8" -> "ru") -_ACCEPT_LANG_TAG_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-[a-zA-Z0-9]+)?\s*(?:;|,|$)") +# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru"). +_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)") + + +def _parse_first_language_code(header: str | None) -> str | None: + """Extract the first language code from Accept-Language header. + + Args: + header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8"). + + Returns: + Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid. + """ + if not header or not header.strip(): + return None + first = header.strip().split(",")[0].strip() + m = _ACCEPT_LANG_CODE_RE.match(first) + return m.group(1).lower() if m else None def _lang_from_accept_language(header: str | None) -> str: @@ -30,14 +47,8 @@ def _lang_from_accept_language(header: str | None) -> str: Returns: 'ru' or 'en'. """ - if not header or not header.strip(): - return config.DEFAULT_LANGUAGE - first = header.strip().split(",")[0].strip() - m = _ACCEPT_LANG_TAG_RE.match(first) - if not m: - return "en" - code = m.group(1).lower() - return "ru" if code.startswith("ru") else "en" + code = _parse_first_language_code(header) + return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE) def _auth_error_detail(auth_reason: str, lang: str) -> str: @@ -51,13 +62,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None: """Validate date range; raise HTTPException with translated detail.""" try: validate_date_range(from_date, to_date) + except DateRangeValidationError as e: + key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to" + raise HTTPException(status_code=400, detail=t(lang, key)) from e except ValueError as e: - msg = str(e) - if "YYYY-MM-DD" in msg or "формате" in msg: - detail = t(lang, "dates.bad_format") - else: - detail = t(lang, "dates.from_after_to") - raise HTTPException(status_code=400, detail=detail) from e + # Backward compatibility if something else raises ValueError. + raise HTTPException(status_code=400, detail=t(lang, "dates.bad_format")) from e def get_validated_dates( @@ -211,9 +221,7 @@ def fetch_duties_response( end_at=duty.end_at, full_name=full_name, event_type=( - duty.event_type - if duty.event_type in ("duty", "unavailable", "vacation") - else "duty" + duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty" ), ) for duty, full_name in rows diff --git a/duty_teller/api/personal_calendar_ics.py b/duty_teller/api/personal_calendar_ics.py index 1a7e46e..36248df 100644 --- a/duty_teller/api/personal_calendar_ics.py +++ b/duty_teller/api/personal_calendar_ics.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from icalendar import Calendar, Event from duty_teller.db.models import Duty +from duty_teller.utils.dates import parse_utc_iso # Summary labels by event_type (duty | unavailable | vacation) SUMMARY_BY_TYPE: dict[str, str] = { @@ -14,16 +15,6 @@ SUMMARY_BY_TYPE: dict[str, str] = { } -def _parse_utc_iso(iso_str: str) -> datetime: - """Parse ISO 8601 UTC string (e.g. 2025-01-15T09:00:00Z) to timezone-aware datetime.""" - s = iso_str.strip().rstrip("Z") - if "Z" in s: - s = s.replace("Z", "+00:00") - else: - s = s + "+00:00" - return datetime.fromisoformat(s) - - def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes: """Build a VCALENDAR (ICS) with one VEVENT per duty. @@ -41,8 +32,8 @@ def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes: for duty, _full_name in duties_with_name: event = Event() - start_dt = _parse_utc_iso(duty.start_at) - end_dt = _parse_utc_iso(duty.end_at) + start_dt = parse_utc_iso(duty.start_at) + end_dt = parse_utc_iso(duty.end_at) # Ensure timezone-aware for icalendar if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=timezone.utc) diff --git a/duty_teller/api/telegram_auth.py b/duty_teller/api/telegram_auth.py index a26eaae..73d7b9c 100644 --- a/duty_teller/api/telegram_auth.py +++ b/duty_teller/api/telegram_auth.py @@ -6,6 +6,8 @@ import json import time from urllib.parse import unquote +from duty_teller.i18n.lang import normalize_lang + # Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app # Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret. @@ -31,14 +33,6 @@ def validate_init_data( return username -def _normalize_lang(language_code: str | None) -> str: - """Normalize to 'ru' or 'en' for i18n.""" - if not language_code or not isinstance(language_code, str): - return "en" - code = language_code.strip().lower() - return "ru" if code.startswith("ru") else "en" - - def validate_init_data_with_reason( init_data: str, bot_token: str, @@ -107,7 +101,7 @@ def validate_init_data_with_reason( return (None, None, "user_invalid", "en") if not isinstance(user, dict): return (None, None, "user_invalid", "en") - lang = _normalize_lang(user.get("language_code")) + lang = normalize_lang(user.get("language_code")) raw_id = user.get("id") if raw_id is None: return (None, None, "no_user_id", lang) diff --git a/duty_teller/config.py b/duty_teller/config.py index 67b551c..144ce82 100644 --- a/duty_teller/config.py +++ b/duty_teller/config.py @@ -4,13 +4,15 @@ BOT_TOKEN is not validated on import; call require_bot_token() in the entry poin when running the bot. """ -import re import os +import re from dataclasses import dataclass from pathlib import Path from dotenv import load_dotenv +from duty_teller.i18n.lang import normalize_lang + load_dotenv() # Project root (parent of duty_teller package). Used for webapp path, etc. @@ -34,14 +36,6 @@ def normalize_phone(phone: str | None) -> str: return _PHONE_DIGITS_RE.sub("", phone.strip()) -def _normalize_default_language(value: str) -> str: - """Normalize DEFAULT_LANGUAGE from env to 'ru' or 'en'.""" - if not value: - return "en" - v = value.strip().lower() - return "ru" if v.startswith("ru") else "en" - - def _parse_phone_list(raw: str) -> set[str]: """Parse comma-separated phones into set of normalized (digits-only) strings.""" result = set() @@ -113,9 +107,7 @@ class Settings: ).strip(), duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() or "Europe/Moscow", - default_language=_normalize_default_language( - os.getenv("DEFAULT_LANGUAGE", "en").strip() - ), + default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")), ) diff --git a/duty_teller/db/repository.py b/duty_teller/db/repository.py index a07564a..2e35ecd 100644 --- a/duty_teller/db/repository.py +++ b/duty_teller/db/repository.py @@ -2,12 +2,13 @@ import hashlib import secrets -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from sqlalchemy.orm import Session import duty_teller.config as config from duty_teller.db.models import User, Duty, GroupDutyPin, CalendarSubscriptionToken +from duty_teller.utils.dates import parse_utc_iso_naive, to_date_exclusive_iso def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None: @@ -168,9 +169,7 @@ def delete_duties_in_range( Returns: Number of duties deleted. """ - to_next = ( - datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) - ).strftime("%Y-%m-%d") + to_next = to_date_exclusive_iso(to_date) q = session.query(Duty).filter( Duty.user_id == user_id, Duty.start_at < to_next, @@ -197,9 +196,7 @@ def get_duties( Returns: List of (Duty, full_name) tuples. """ - to_date_next = ( - datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) - ).strftime("%Y-%m-%d") + to_date_next = to_date_exclusive_iso(to_date) q = ( session.query(Duty, User.full_name) .join(User, Duty.user_id == User.id) @@ -230,9 +227,7 @@ def get_duties_for_user( Returns: List of (Duty, full_name) tuples. """ - to_date_next = ( - datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) - ).strftime("%Y-%m-%d") + to_date_next = to_date_exclusive_iso(to_date) filters = [ Duty.user_id == user_id, Duty.start_at < to_date_next, @@ -392,9 +387,7 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None .first() ) if current: - return datetime.fromisoformat(current.end_at.replace("Z", "+00:00")).replace( - tzinfo=None - ) + return parse_utc_iso_naive(current.end_at) next_duty = ( session.query(Duty) .filter(Duty.event_type == "duty", Duty.start_at > after_iso) @@ -402,9 +395,7 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None .first() ) if next_duty: - return datetime.fromisoformat(next_duty.end_at.replace("Z", "+00:00")).replace( - tzinfo=None - ) + return parse_utc_iso_naive(next_duty.end_at) return None diff --git a/duty_teller/db/schemas.py b/duty_teller/db/schemas.py index 27d7a40..fb55e8c 100644 --- a/duty_teller/db/schemas.py +++ b/duty_teller/db/schemas.py @@ -4,6 +4,9 @@ from typing import Literal from pydantic import BaseModel, ConfigDict +# Allowed duty event types; API maps unknown DB values to "duty". +DUTY_EVENT_TYPES = ("duty", "unavailable", "vacation") + class UserBase(BaseModel): """Base user fields (full_name, username, first/last name).""" diff --git a/duty_teller/handlers/commands.py b/duty_teller/handlers/commands.py index a0b2f46..1fb050d 100644 --- a/duty_teller/handlers/commands.py +++ b/duty_teller/handlers/commands.py @@ -11,8 +11,8 @@ from duty_teller.db.repository import ( get_or_create_user, set_user_phone, create_calendar_token, - is_admin_for_telegram_user, ) +from duty_teller.handlers.common import is_admin_async from duty_teller.i18n import get_lang, t from duty_teller.utils.user import build_full_name @@ -151,13 +151,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: t(lang, "help.calendar_link"), t(lang, "help.pin_duty"), ] - - def check_admin() -> bool: - with session_scope(config.DATABASE_URL) as session: - return is_admin_for_telegram_user(session, update.effective_user.id) - - is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin) - if is_admin_user: + if await is_admin_async(update.effective_user.id): lines.append(t(lang, "help.import_schedule")) await update.message.reply_text("\n".join(lines)) diff --git a/duty_teller/handlers/common.py b/duty_teller/handlers/common.py new file mode 100644 index 0000000..076a5a9 --- /dev/null +++ b/duty_teller/handlers/common.py @@ -0,0 +1,24 @@ +"""Shared handler helpers (e.g. async admin check).""" + +import asyncio + +import duty_teller.config as config +from duty_teller.db.repository import is_admin_for_telegram_user +from duty_teller.db.session import session_scope + + +async def is_admin_async(telegram_user_id: int) -> bool: + """Check if Telegram user is admin (username or phone). Runs DB check in executor. + + Args: + telegram_user_id: Telegram user id. + + Returns: + True if user is in ADMIN_USERNAMES or their stored phone is in ADMIN_PHONES. + """ + + def _check() -> bool: + with session_scope(config.DATABASE_URL) as session: + return is_admin_for_telegram_user(session, telegram_user_id) + + return await asyncio.get_running_loop().run_in_executor(None, _check) diff --git a/duty_teller/handlers/import_duty_schedule.py b/duty_teller/handlers/import_duty_schedule.py index 9ced849..4a09a64 100644 --- a/duty_teller/handlers/import_duty_schedule.py +++ b/duty_teller/handlers/import_duty_schedule.py @@ -7,7 +7,7 @@ from telegram import Update from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters from duty_teller.db.session import session_scope -from duty_teller.db.repository import is_admin_for_telegram_user +from duty_teller.handlers.common import is_admin_async from duty_teller.i18n import get_lang, t from duty_teller.importers.duty_schedule import ( DutyScheduleParseError, @@ -24,13 +24,7 @@ async def import_duty_schedule_cmd( if not update.message or not update.effective_user: return lang = get_lang(update.effective_user) - - def check_admin() -> bool: - with session_scope(config.DATABASE_URL) as session: - return is_admin_for_telegram_user(session, update.effective_user.id) - - is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin) - if not is_admin_user: + if not await is_admin_async(update.effective_user.id): await update.message.reply_text(t(lang, "import.admin_only")) return context.user_data["awaiting_handover_time"] = True @@ -45,13 +39,7 @@ async def handle_handover_time_text( return if not context.user_data.get("awaiting_handover_time"): return - - def check_admin() -> bool: - with session_scope(config.DATABASE_URL) as session: - return is_admin_for_telegram_user(session, update.effective_user.id) - - is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin) - if not is_admin_user: + if not await is_admin_async(update.effective_user.id): return lang = get_lang(update.effective_user) text = update.message.text.strip() @@ -78,13 +66,7 @@ async def handle_duty_schedule_document( handover = context.user_data.get("handover_utc_time") if not handover: return - - def check_admin() -> bool: - with session_scope(config.DATABASE_URL) as session: - return is_admin_for_telegram_user(session, update.effective_user.id) - - is_admin_user = await asyncio.get_running_loop().run_in_executor(None, check_admin) - if not is_admin_user: + if not await is_admin_async(update.effective_user.id): return if not (update.message.document.file_name or "").lower().endswith(".json"): await update.message.reply_text(t(lang, "import.need_json")) diff --git a/duty_teller/i18n/__init__.py b/duty_teller/i18n/__init__.py index 7b1b385..7431f82 100644 --- a/duty_teller/i18n/__init__.py +++ b/duty_teller/i18n/__init__.py @@ -1,6 +1,7 @@ """Internationalization: RU/EN by Telegram language_code. Normalize to 'ru' or 'en'.""" -from duty_teller.i18n.messages import MESSAGES from duty_teller.i18n.core import get_lang, t +from duty_teller.i18n.lang import normalize_lang +from duty_teller.i18n.messages import MESSAGES -__all__ = ["MESSAGES", "get_lang", "t"] +__all__ = ["MESSAGES", "get_lang", "normalize_lang", "t"] diff --git a/duty_teller/i18n/core.py b/duty_teller/i18n/core.py index e84c640..ddb332e 100644 --- a/duty_teller/i18n/core.py +++ b/duty_teller/i18n/core.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING import duty_teller.config as config +from duty_teller.i18n.lang import normalize_lang from duty_teller.i18n.messages import MESSAGES if TYPE_CHECKING: @@ -12,13 +13,12 @@ if TYPE_CHECKING: def get_lang(user: "User | None") -> str: """ Normalize Telegram user language to 'ru' or 'en'. - If user has language_code starting with 'ru' (e.g. ru, ru-RU) return 'ru', else 'en'. - When user is None or has no language_code, return config.DEFAULT_LANGUAGE. + Uses normalize_lang for user.language_code; when user is None or has no + language_code, returns config.DEFAULT_LANGUAGE. """ if user is None or not getattr(user, "language_code", None): return config.DEFAULT_LANGUAGE - code = (user.language_code or "").strip().lower() - return "ru" if code.startswith("ru") else "en" + return normalize_lang(user.language_code) def t(lang: str, key: str, **kwargs: str) -> str: diff --git a/duty_teller/i18n/lang.py b/duty_teller/i18n/lang.py new file mode 100644 index 0000000..d5b1eee --- /dev/null +++ b/duty_teller/i18n/lang.py @@ -0,0 +1,26 @@ +"""Single source of truth for normalizing language codes to 'ru' or 'en'. + +Use for: env DEFAULT_LANGUAGE, Accept-Language header, Telegram user.language_code, +and initData user object in Miniapp auth. +""" + +from typing import Literal + + +def normalize_lang(code: str | None) -> Literal["ru", "en"]: + """Normalize a language code to 'ru' or 'en'. + + Suitable for: env DEFAULT_LANGUAGE, Accept-Language header, + Telegram user.language_code, and initData user in Miniapp auth. + + Args: + code: Raw language code (e.g. 'ru', 'ru-RU', 'en', 'en-US') or None. + + Returns: + 'ru' if code starts with 'ru' (after strip/lower), else 'en'. + Returns 'en' when code is empty or not a string. + """ + if not code or not isinstance(code, str): + return "en" + code = code.strip().lower() + return "ru" if code.startswith("ru") else "en" diff --git a/duty_teller/services/group_duty_pin_service.py b/duty_teller/services/group_duty_pin_service.py index 6f914ce..07e2106 100644 --- a/duty_teller/services/group_duty_pin_service.py +++ b/duty_teller/services/group_duty_pin_service.py @@ -14,6 +14,7 @@ from duty_teller.db.repository import ( get_all_group_duty_pin_chat_ids, ) from duty_teller.i18n import t +from duty_teller.utils.dates import parse_utc_iso def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str: @@ -35,8 +36,8 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str: except ZoneInfoNotFoundError: tz = ZoneInfo("Europe/Moscow") tz_name = "Europe/Moscow" - start_dt = datetime.fromisoformat(duty.start_at.replace("Z", "+00:00")) - end_dt = datetime.fromisoformat(duty.end_at.replace("Z", "+00:00")) + start_dt = parse_utc_iso(duty.start_at) + end_dt = parse_utc_iso(duty.end_at) start_local = start_dt.astimezone(tz) end_local = end_dt.astimezone(tz) offset_sec = ( diff --git a/duty_teller/utils/dates.py b/duty_teller/utils/dates.py index 8cbd219..f6b75b1 100644 --- a/duty_teller/utils/dates.py +++ b/duty_teller/utils/dates.py @@ -1,7 +1,8 @@ """Date and ISO helpers for duty ranges and API validation.""" import re -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone +from typing import Literal def day_start_iso(d: date) -> str: @@ -23,6 +24,57 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str: _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +class DateRangeValidationError(ValueError): + """Raised when from_date/to_date validation fails. API uses kind for i18n key.""" + + def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None: + self.kind = kind + super().__init__(kind) + + +def to_date_exclusive_iso(to_date: str) -> str: + """Return the day after to_date in YYYY-MM-DD for exclusive range end. + + Use for queries like start_at < to_date_next (e.g. filter end before next day). + + Args: + to_date: End date in YYYY-MM-DD. + + Returns: + (to_date + 1 day) in YYYY-MM-DD. + """ + dt = datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +def parse_utc_iso(iso_str: str) -> datetime: + """Parse UTC ISO 8601 string with Z suffix to timezone-aware datetime (UTC). + + Args: + iso_str: e.g. '2025-01-15T09:00:00Z'. + + Returns: + Timezone-aware datetime in UTC. + """ + normalized = (iso_str or "").strip().replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def parse_utc_iso_naive(iso_str: str) -> datetime: + """Parse UTC ISO 8601 string with Z to naive datetime in UTC. + + Use for job queue or code that expects naive UTC. + + Args: + iso_str: e.g. '2025-01-15T09:00:00Z'. + + Returns: + Naive datetime with same UTC wall time. + """ + dt = parse_utc_iso(iso_str) + return dt.replace(tzinfo=None) + + def parse_iso_date(s: str) -> date | None: """Parse YYYY-MM-DD string to date. Returns None if invalid.""" if not s or not _ISO_DATE_RE.match(s.strip()): @@ -34,8 +86,12 @@ def parse_iso_date(s: str) -> date | None: def validate_date_range(from_date: str, to_date: str) -> None: - """Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. Raises ValueError if invalid.""" + """Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. + + Raises: + DateRangeValidationError: bad_format if format invalid, from_after_to if from > to. + """ if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""): - raise ValueError("Параметры from и to должны быть в формате YYYY-MM-DD") + raise DateRangeValidationError("bad_format") if from_date > to_date: - raise ValueError("Дата from не должна быть позже to") + raise DateRangeValidationError("from_after_to") diff --git a/duty_teller/utils/handover.py b/duty_teller/utils/handover.py index d737f8c..073f04b 100644 --- a/duty_teller/utils/handover.py +++ b/duty_teller/utils/handover.py @@ -20,13 +20,8 @@ def parse_handover_time(text: str) -> tuple[int, int] | None: tz_str = (m.group(4) or "").strip() if not tz_str or tz_str.upper() == "UTC": return (hour % 24, minute) - try: - from zoneinfo import ZoneInfo - except ImportError: - try: - from backports.zoneinfo import ZoneInfo # type: ignore - except ImportError: - return None + from zoneinfo import ZoneInfo + try: tz = ZoneInfo(tz_str) except Exception: diff --git a/tests/test_handlers_commands.py b/tests/test_handlers_commands.py index 35eb636..c3a8068 100644 --- a/tests/test_handlers_commands.py +++ b/tests/test_handlers_commands.py @@ -235,18 +235,15 @@ async def test_help_cmd_replies_with_help_text(): 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()) + with patch( + "duty_teller.handlers.commands.is_admin_async", + new_callable=AsyncMock, + 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 diff --git a/tests/test_utils.py b/tests/test_utils.py index 2a9f02e..a1968f3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,6 +5,7 @@ from datetime import date import pytest from duty_teller.utils.dates import ( + DateRangeValidationError, day_start_iso, day_end_iso, duty_to_iso, @@ -47,15 +48,18 @@ def test_validate_date_range_ok(): def test_validate_date_range_bad_format(): - with pytest.raises(ValueError, match="формате YYYY-MM-DD"): + with pytest.raises(DateRangeValidationError) as exc_info: validate_date_range("01-01-2025", "2025-01-31") - with pytest.raises(ValueError, match="формате YYYY-MM-DD"): + assert exc_info.value.kind == "bad_format" + with pytest.raises(DateRangeValidationError) as exc_info: validate_date_range("2025-01-01", "invalid") + assert exc_info.value.kind == "bad_format" def test_validate_date_range_from_after_to(): - with pytest.raises(ValueError, match="from не должна быть позже"): + with pytest.raises(DateRangeValidationError) as exc_info: validate_date_range("2025-02-01", "2025-01-01") + assert exc_info.value.kind == "from_after_to" # --- user --- diff --git a/webapp/js/api.js b/webapp/js/api.js index 58e0e90..78f3335 100644 --- a/webapp/js/api.js +++ b/webapp/js/api.js @@ -21,6 +21,27 @@ export function buildFetchOptions(initData) { return { headers, signal: controller.signal, timeoutId }; } +/** + * GET request to API with init data, Accept-Language, timeout. + * Caller checks res.ok, res.status, res.json(). + * @param {string} path - e.g. "/api/duties" + * @param {{ from?: string, to?: string }} params - query params + * @returns {Promise} - raw response + */ +export async function apiGet(path, params = {}) { + const base = window.location.origin; + const query = new URLSearchParams(params).toString(); + const url = query ? `${base}${path}?${query}` : `${base}${path}`; + const initData = getInitData(); + const opts = buildFetchOptions(initData); + try { + const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); + return res; + } finally { + clearTimeout(opts.timeoutId); + } +} + /** * Fetch duties for date range. Throws ACCESS_DENIED error on 403. * @param {string} from - YYYY-MM-DD @@ -28,17 +49,8 @@ export function buildFetchOptions(initData) { * @returns {Promise} */ export async function fetchDuties(from, to) { - const base = window.location.origin; - const url = - base + - "/api/duties?from=" + - encodeURIComponent(from) + - "&to=" + - encodeURIComponent(to); - const initData = getInitData(); - const opts = buildFetchOptions(initData); try { - const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); + const res = await apiGet("/api/duties", { from, to }); if (res.status === 403) { let detail = t(state.lang, "access_denied"); try { @@ -63,8 +75,6 @@ export async function fetchDuties(from, to) { throw new Error(t(state.lang, "error_network")); } throw e; - } finally { - clearTimeout(opts.timeoutId); } } @@ -75,22 +85,11 @@ export async function fetchDuties(from, to) { * @returns {Promise} */ export async function fetchCalendarEvents(from, to) { - const base = window.location.origin; - const url = - base + - "/api/calendar-events?from=" + - encodeURIComponent(from) + - "&to=" + - encodeURIComponent(to); - const initData = getInitData(); - const opts = buildFetchOptions(initData); try { - const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); + const res = await apiGet("/api/calendar-events", { from, to }); if (!res.ok) return []; return res.json(); } catch (e) { return []; - } finally { - clearTimeout(opts.timeoutId); } }