feat: unify language handling across the application

- Updated the language configuration to use a single source of truth from `DEFAULT_LANGUAGE` for the bot, API, and Mini App, eliminating auto-detection from user settings.
- Refactored the `get_lang` function to always return `DEFAULT_LANGUAGE`, ensuring consistent language usage throughout the application.
- Modified the handling of language in various components, including API responses and UI elements, to reflect the new language management approach.
- Enhanced documentation and comments to clarify the changes in language handling.
- Added unit tests to verify the new language handling behavior and ensure coverage for the updated functionality.
This commit is contained in:
2026-03-02 23:05:28 +03:00
parent 54446d7b0f
commit 67ba9826c7
21 changed files with 446 additions and 205 deletions

View File

@@ -35,7 +35,7 @@ export const state = {
current: new Date(), // currently displayed month current: new Date(), // currently displayed month
lastDutiesForList: [], // duties array for the duty list lastDutiesForList: [], // duties array for the duty list
todayRefreshInterval: null, // interval handle todayRefreshInterval: null, // interval handle
lang: "ru" // 'ru' | 'en' lang: "en" // 'ru' | 'en'
}; };
``` ```
@@ -58,7 +58,7 @@ t(lang, key, params?) // → translated string
``` ```
- `lang` is `'ru'` or `'en'`, stored in `state.lang`. - `lang` is `'ru'` or `'en'`, stored in `state.lang`.
- `getLang()` resolves language from: Telegram `initData` user → `navigator.language` → `"ru"`. - `getLang()` reads **backend config** only: `window.__DT_LANG` (set by `/app/config.js` from `DEFAULT_LANGUAGE`). If missing or invalid, falls back to `"en"`. No Telegram or navigator language detection.
- Params use named placeholders: `t(lang, "duty.until", { time: "14:00" })`. - Params use named placeholders: `t(lang, "duty.until", { time: "14:00" })`.
- Fallback chain: `MESSAGES[lang][key]` → `MESSAGES.en[key]` → raw key string. - Fallback chain: `MESSAGES[lang][key]` → `MESSAGES.en[key]` → raw key string.
- All user-visible text must go through `t()` — never hardcode Russian strings in JS. - All user-visible text must go through `t()` — never hardcode Russian strings in JS.

View File

@@ -26,7 +26,7 @@ ADMIN_USERNAMES=admin1,admin2
# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable. # When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable.
# DUTY_PIN_NOTIFY=1 # DUTY_PIN_NOTIFY=1
# Default UI language when user language is unknown: en or ru (default: en). # Single source of language for bot, API, and Mini App (en or ru). Default: en. No auto-detection.
# DEFAULT_LANGUAGE=en # DEFAULT_LANGUAGE=en
# Reject Telegram initData older than this (seconds). 0 = do not check (default). # Reject Telegram initData older than this (seconds). 0 = do not check (default).

View File

@@ -19,7 +19,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
| **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. | | **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. |
| **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. | | **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. |
| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. | | **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. |
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. | | **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | **Single source of language for the whole deployment:** bot messages, API error texts, and Mini App UI all use this value. No auto-detection from Telegram user, browser, or `Accept-Language`. Values starting with `ru` are normalized to `ru`; anything else becomes `en`. |
## Roles and access ## Roles and access

View File

@@ -5,7 +5,6 @@ import re
from datetime import date, timedelta from datetime import date, timedelta
import duty_teller.config as config import duty_teller.config as config
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -58,22 +57,67 @@ app.add_middleware(
) )
class NoCacheStaticMiddleware(BaseHTTPMiddleware): class NoCacheStaticMiddleware:
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.).""" """
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
"""
async def dispatch(self, request, call_next): def __init__(self, app, **kwargs):
response = await call_next(request) self.app = app
path = request.url.path
if path.startswith("/app/") and ( async def __call__(self, scope, receive, send):
path.endswith(".js") or path.endswith(".html") if scope["type"] != "http":
): await self.app(scope, receive, send)
response.headers["Cache-Control"] = "no-store" return
return response
path = scope.get("path", "")
is_app_path = path == "/app" or path.startswith("/app/")
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
if is_app_path:
cache_control = (b"cache-control", b"no-store")
if b"cache-control" in header_names:
headers[header_names[b"cache-control"]] = cache_control
else:
headers.append(cache_control)
vary_val = b"Accept-Language"
if b"vary" in header_names:
idx = header_names[b"vary"]
existing = headers[idx][1]
tokens = [p.strip() for p in existing.split(b",")]
if vary_val not in tokens:
headers[idx] = (b"vary", existing + b", " + vary_val)
else:
headers.append((b"vary", vary_val))
message = {"type": "http.response.start", "status": message["status"], "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(NoCacheStaticMiddleware) app.add_middleware(NoCacheStaticMiddleware)
@app.get(
"/app/config.js",
summary="Mini App config (language)",
description="Returns JS that sets window.__DT_LANG from DEFAULT_LANGUAGE. Loaded before main.js.",
)
def app_config_js() -> Response:
"""Return JS assigning window.__DT_LANG for the webapp. No caching."""
lang = config.DEFAULT_LANGUAGE
body = f'window.__DT_LANG = "{lang}";'
return Response(
content=body,
media_type="application/javascript; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
@app.get( @app.get(
"/api/duties", "/api/duties",
response_model=list[DutyWithUser], response_model=list[DutyWithUser],

View File

@@ -1,7 +1,6 @@
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation.""" """FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
import logging import logging
import re
from typing import Annotated, Generator from typing import Annotated, Generator
from fastapi import Depends, Header, HTTPException, Query, Request from fastapi import Depends, Header, HTTPException, Query, Request
@@ -17,42 +16,17 @@ from duty_teller.db.repository import (
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
from duty_teller.db.session import session_scope from duty_teller.db.session import session_scope
from duty_teller.i18n import t from duty_teller.i18n import t
from duty_teller.i18n.lang import normalize_lang
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# 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: def _lang_from_accept_language(header: str | None) -> str:
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE. """Return the application language: always config.DEFAULT_LANGUAGE.
Args: The header argument is kept for backward compatibility but is ignored.
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8"). The whole deployment uses a single language from DEFAULT_LANGUAGE.
Returns:
'ru' or 'en'.
""" """
code = _parse_first_language_code(header) return config.DEFAULT_LANGUAGE
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
def _auth_error_detail(auth_reason: str, lang: str) -> str: def _auth_error_detail(auth_reason: str, lang: str) -> str:

View File

@@ -6,7 +6,7 @@ import json
import time import time
from urllib.parse import unquote from urllib.parse import unquote
from duty_teller.i18n.lang import normalize_lang import duty_teller.config as config
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app # 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. # Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
@@ -48,12 +48,12 @@ def validate_init_data_with_reason(
Returns: Returns:
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok", Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
"user_invalid", "no_user_id". lang is from user.language_code normalized "user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None, On success: (user.id, username or None, "ok", lang).
"ok", lang).
""" """
lang = config.DEFAULT_LANGUAGE
if not init_data or not bot_token: if not init_data or not bot_token:
return (None, None, "empty", "en") return (None, None, "empty", lang)
init_data = init_data.strip() init_data = init_data.strip()
params = {} params = {}
for part in init_data.split("&"): for part in init_data.split("&"):
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
params[key] = value params[key] = value
hash_val = params.pop("hash", None) hash_val = params.pop("hash", None)
if not hash_val: if not hash_val:
return (None, None, "no_hash", "en") return (None, None, "no_hash", lang)
data_pairs = sorted(params.items()) data_pairs = sorted(params.items())
# Data-check string: key=value with URL-decoded values (per Telegram example) # Data-check string: key=value with URL-decoded values (per Telegram example)
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs) data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
digestmod=hashlib.sha256, digestmod=hashlib.sha256,
).hexdigest() ).hexdigest()
if not hmac.compare_digest(computed.lower(), hash_val.lower()): if not hmac.compare_digest(computed.lower(), hash_val.lower()):
return (None, None, "hash_mismatch", "en") return (None, None, "hash_mismatch", lang)
if max_age_seconds is not None and max_age_seconds > 0: if max_age_seconds is not None and max_age_seconds > 0:
auth_date_raw = params.get("auth_date") auth_date_raw = params.get("auth_date")
if not auth_date_raw: if not auth_date_raw:
return (None, None, "auth_date_expired", "en") return (None, None, "auth_date_expired", lang)
try: try:
auth_date = int(float(auth_date_raw)) auth_date = int(float(auth_date_raw))
except (ValueError, TypeError): except (ValueError, TypeError):
return (None, None, "auth_date_expired", "en") return (None, None, "auth_date_expired", lang)
if time.time() - auth_date > max_age_seconds: if time.time() - auth_date > max_age_seconds:
return (None, None, "auth_date_expired", "en") return (None, None, "auth_date_expired", lang)
user_raw = params.get("user") user_raw = params.get("user")
if not user_raw: if not user_raw:
return (None, None, "no_user", "en") return (None, None, "no_user", lang)
try: try:
user = json.loads(unquote(user_raw)) user = json.loads(unquote(user_raw))
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return (None, None, "user_invalid", "en") return (None, None, "user_invalid", lang)
if not isinstance(user, dict): if not isinstance(user, dict):
return (None, None, "user_invalid", "en") return (None, None, "user_invalid", lang)
lang = normalize_lang(user.get("language_code"))
raw_id = user.get("id") raw_id = user.get("id")
if raw_id is None: if raw_id is None:
return (None, None, "no_user_id", lang) return (None, None, "no_user_id", lang)

View File

@@ -1,9 +1,8 @@
"""get_lang and t(): language from Telegram user, translate by key with fallback to en.""" """get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en."""
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import duty_teller.config as config import duty_teller.config as config
from duty_teller.i18n.lang import normalize_lang
from duty_teller.i18n.messages import MESSAGES from duty_teller.i18n.messages import MESSAGES
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
def get_lang(user: "User | None") -> str: def get_lang(user: "User | None") -> str:
""" """
Normalize Telegram user language to 'ru' or 'en'. Return the application language: always config.DEFAULT_LANGUAGE.
Uses normalize_lang for user.language_code; when user is None or has no
language_code, returns config.DEFAULT_LANGUAGE. The user argument is kept for backward compatibility but is ignored.
The whole deployment uses a single language from DEFAULT_LANGUAGE.
""" """
if user is None or not getattr(user, "language_code", None): return config.DEFAULT_LANGUAGE
return config.DEFAULT_LANGUAGE
return normalize_lang(user.language_code)
def t(lang: str, key: str, **kwargs: str) -> str: def t(lang: str, key: str, **kwargs: str) -> str:

View File

@@ -10,24 +10,27 @@ import duty_teller.config as config
class TestLangFromAcceptLanguage: class TestLangFromAcceptLanguage:
"""Tests for _lang_from_accept_language.""" """Tests for _lang_from_accept_language: always returns config.DEFAULT_LANGUAGE."""
def test_none_returns_default(self): def test_always_returns_default_language(self):
"""Header is ignored; result is always config.DEFAULT_LANGUAGE."""
assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE 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
assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language("en-US") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language("zz") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language("x") == config.DEFAULT_LANGUAGE
def test_ru_ru_returns_ru(self): def test_returns_ru_when_default_language_is_ru(self):
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru" with patch.object(config, "DEFAULT_LANGUAGE", "ru"):
assert deps._lang_from_accept_language("en-US") == "ru"
assert deps._lang_from_accept_language(None) == "ru"
def test_en_us_returns_en(self): def test_returns_en_when_default_language_is_en(self):
assert deps._lang_from_accept_language("en-US") == "en" with patch.object(config, "DEFAULT_LANGUAGE", "en"):
assert deps._lang_from_accept_language("ru-RU") == "en"
def test_invalid_fallback_to_en(self): assert deps._lang_from_accept_language(None) == "en"
assert deps._lang_from_accept_language("zz") == "en"
assert deps._lang_from_accept_language("x") == "en"
class TestAuthErrorDetail: class TestAuthErrorDetail:

View File

@@ -23,6 +23,41 @@ def test_health(client):
assert r.json() == {"status": "ok"} assert r.json() == {"status": "ok"}
def test_health_has_vary_accept_language(client):
"""NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
r = client.get("/health")
assert r.status_code == 200
assert "accept-language" in r.headers.get("vary", "").lower()
def test_app_static_has_no_store_and_vary(client):
"""Static files under /app get Cache-Control: no-store and Vary: Accept-Language."""
r = client.get("/app/")
if r.status_code != 200:
r = client.get("/app")
assert r.status_code == 200, "webapp static mount should serve index at /app or /app/"
assert r.headers.get("cache-control") == "no-store"
assert "accept-language" in r.headers.get("vary", "").lower()
def test_app_js_has_no_store(client):
"""JS and all static under /app get Cache-Control: no-store."""
r = client.get("/app/js/main.js")
assert r.status_code == 200
assert r.headers.get("cache-control") == "no-store"
def test_app_config_js_returns_lang_from_default_language(client):
"""GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE."""
r = client.get("/app/config.js")
assert r.status_code == 200
assert r.headers.get("content-type", "").startswith("application/javascript")
assert r.headers.get("cache-control") == "no-store"
body = r.text
assert "window.__DT_LANG" in body
assert config.DEFAULT_LANGUAGE in body
def test_duties_invalid_date_format(client): def test_duties_invalid_date_format(client):
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"}) r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
assert r.status_code == 400 assert r.status_code == 400

View File

@@ -1,48 +1,46 @@
"""Unit tests for duty_teller.i18n: get_lang, t, fallback to en.""" """Unit tests for duty_teller.i18n: get_lang, t, fallback to en."""
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import duty_teller.config as config
from duty_teller.i18n import get_lang, t from duty_teller.i18n import get_lang, t
def test_get_lang_none_returns_en(): def test_get_lang_always_returns_default_language():
assert get_lang(None) == "en" """get_lang ignores user and always returns config.DEFAULT_LANGUAGE."""
assert get_lang(None) == config.DEFAULT_LANGUAGE
user_ru = MagicMock()
user_ru.language_code = "ru"
assert get_lang(user_ru) == config.DEFAULT_LANGUAGE
user_en = MagicMock()
user_en.language_code = "en"
assert get_lang(user_en) == config.DEFAULT_LANGUAGE
user_any = MagicMock(spec=[])
assert get_lang(user_any) == config.DEFAULT_LANGUAGE
def test_get_lang_ru_returns_ru(): def test_get_lang_returns_ru_when_default_language_is_ru():
user = MagicMock() """When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
user.language_code = "ru" with patch("duty_teller.i18n.core.config") as mock_cfg:
assert get_lang(user) == "ru" mock_cfg.DEFAULT_LANGUAGE = "ru"
from duty_teller.i18n.core import get_lang as core_get_lang
assert core_get_lang(None) == "ru"
user = MagicMock()
user.language_code = "en"
assert core_get_lang(user) == "ru"
def test_get_lang_ru_ru_returns_ru(): def test_get_lang_returns_en_when_default_language_is_en():
user = MagicMock() """When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
user.language_code = "ru-RU" with patch("duty_teller.i18n.core.config") as mock_cfg:
assert get_lang(user) == "ru" mock_cfg.DEFAULT_LANGUAGE = "en"
from duty_teller.i18n.core import get_lang as core_get_lang
assert core_get_lang(None) == "en"
def test_get_lang_en_returns_en(): user = MagicMock()
user = MagicMock() user.language_code = "ru"
user.language_code = "en" assert core_get_lang(user) == "en"
assert get_lang(user) == "en"
def test_get_lang_uk_returns_en():
user = MagicMock()
user.language_code = "uk"
assert get_lang(user) == "en"
def test_get_lang_empty_returns_en():
user = MagicMock()
user.language_code = ""
assert get_lang(user) == "en"
def test_get_lang_missing_attr_returns_en():
user = MagicMock(spec=[]) # no language_code
assert get_lang(user) == "en"
def test_t_en_start_greeting(): def test_t_en_start_greeting():

View File

@@ -1,5 +1,8 @@
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason.""" """Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
from unittest.mock import patch
import duty_teller.config as config
from duty_teller.api.telegram_auth import ( from duty_teller.api.telegram_auth import (
validate_init_data, validate_init_data,
validate_init_data_with_reason, validate_init_data_with_reason,
@@ -52,7 +55,7 @@ def test_user_without_username_returns_none_from_validate_init_data():
def test_user_without_username_but_with_id_succeeds_with_reason(): def test_user_without_username_but_with_id_succeeds_with_reason():
"""With validate_init_data_with_reason, valid user.id is enough; username may be None.""" """With validate_init_data_with_reason, valid user.id is enough; lang is DEFAULT_LANGUAGE."""
bot_token = "123:ABC" bot_token = "123:ABC"
user = {"id": 456, "first_name": "Test", "language_code": "ru"} user = {"id": 456, "first_name": "Test", "language_code": "ru"}
init_data = make_init_data(user, bot_token) init_data = make_init_data(user, bot_token)
@@ -62,11 +65,11 @@ def test_user_without_username_but_with_id_succeeds_with_reason():
assert telegram_user_id == 456 assert telegram_user_id == 456
assert username is None assert username is None
assert reason == "ok" assert reason == "ok"
assert lang == "ru" assert lang == config.DEFAULT_LANGUAGE
def test_user_without_id_returns_no_user_id(): def test_user_without_id_returns_no_user_id():
"""When user object exists but has no 'id', return no_user_id.""" """When user object exists but has no 'id', return no_user_id; lang is DEFAULT_LANGUAGE."""
bot_token = "123:ABC" bot_token = "123:ABC"
user = {"first_name": "Test"} # no id user = {"first_name": "Test"} # no id
init_data = make_init_data(user, bot_token) init_data = make_init_data(user, bot_token)
@@ -76,7 +79,17 @@ def test_user_without_id_returns_no_user_id():
assert telegram_user_id is None assert telegram_user_id is None
assert username is None assert username is None
assert reason == "no_user_id" assert reason == "no_user_id"
assert lang == "en" assert lang == config.DEFAULT_LANGUAGE
def test_validate_init_data_with_reason_returns_default_language_ignoring_user_lang():
"""Returned lang is always config.DEFAULT_LANGUAGE, not user.language_code."""
with patch("duty_teller.api.telegram_auth.config.DEFAULT_LANGUAGE", "ru"):
user = {"id": 1, "first_name": "U", "language_code": "en"}
init_data = make_init_data(user, "123:ABC")
_, _, reason, lang = validate_init_data_with_reason(init_data, "123:ABC")
assert reason == "ok"
assert lang == "ru"
def test_empty_init_data_returns_none(): def test_empty_init_data_returns_none():

View File

@@ -152,6 +152,19 @@
border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent); border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
} }
@media (hover: hover) {
.day.holiday:hover {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--accent) 12%, var(--surface)) 0%,
color-mix(in srgb, var(--today) 22%, transparent) 100%
);
}
.day.today.holiday:hover {
background: color-mix(in srgb, var(--bg) 15%, var(--today));
}
}
.day { .day {
cursor: pointer; cursor: pointer;
} }

View File

@@ -36,15 +36,66 @@
<div id="currentDutyView" class="current-duty-view hidden"></div> <div id="currentDutyView" class="current-duty-view hidden"></div>
</div> </div>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script>
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.ready) {
window.Telegram.WebApp.ready();
}
</script>
<script src="/app/config.js"></script>
<script type="importmap"> <script type="importmap">
{ {
"scopes": { "scopes": {
"./js/": { "./js/": {
"./js/i18n.js": "./js/i18n.js?v=1" "./js/api.js": "./js/api.js?v=3",
"./js/auth.js": "./js/auth.js?v=3",
"./js/calendar.js": "./js/calendar.js?v=2",
"./js/constants.js": "./js/constants.js?v=2",
"./js/contactHtml.js": "./js/contactHtml.js?v=2",
"./js/currentDuty.js": "./js/currentDuty.js?v=2",
"./js/dateUtils.js": "./js/dateUtils.js?v=2",
"./js/dayDetail.js": "./js/dayDetail.js?v=2",
"./js/dom.js": "./js/dom.js?v=3",
"./js/dutyList.js": "./js/dutyList.js?v=2",
"./js/hints.js": "./js/hints.js?v=2",
"./js/i18n.js": "./js/i18n.js?v=3",
"./js/theme.js": "./js/theme.js?v=2",
"./js/ui.js": "./js/ui.js?v=2",
"./js/utils.js": "./js/utils.js?v=2"
} }
} }
} }
</script> </script>
<script type="module" src="js/main.js?v=4"></script> <script type="module" src="js/main.js?v=5" id="main-module"></script>
<script>
(function() {
var loadTimeout = 10000;
var mainScript = document.getElementById("main-module");
if (mainScript) {
mainScript.addEventListener("error", function() {
var loading = document.getElementById("loading");
if (loading && !loading.classList.contains("hidden")) {
loading.classList.add("hidden");
var err = document.getElementById("error");
if (err) {
err.hidden = false;
err.textContent = "Failed to load app. Check connection and try again.";
}
}
});
}
setTimeout(function() {
if (window.__dtReady) return;
var loading = document.getElementById("loading");
if (loading && !loading.classList.contains("hidden")) {
loading.classList.add("hidden");
var err = document.getElementById("error");
if (err) {
err.hidden = false;
err.textContent = "App is taking too long to load. Check your connection and refresh.";
}
}
}, loadTimeout);
})();
</script>
</body> </body>
</html> </html>

View File

@@ -17,7 +17,7 @@ import { t } from "./i18n.js";
export function buildFetchOptions(initData, externalSignal) { export function buildFetchOptions(initData, externalSignal) {
const headers = {}; const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData; if (initData) headers["X-Telegram-Init-Data"] = initData;
headers["Accept-Language"] = state.lang || "ru"; headers["Accept-Language"] = state.lang || "en";
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const onAbort = () => controller.abort(); const onAbort = () => controller.abort();

View File

@@ -34,23 +34,15 @@ export function getInitData() {
if (hash) { if (hash) {
const fromHash = getTgWebAppDataFromHash(hash); const fromHash = getTgWebAppDataFromHash(hash);
if (fromHash) return fromHash; if (fromHash) return fromHash;
try { const hashParams = new URLSearchParams(hash);
const hashParams = new URLSearchParams(hash); const tgFromHash = hashParams.get("tgWebAppData");
const tgFromHash = hashParams.get("tgWebAppData"); if (tgFromHash) return tgFromHash;
if (tgFromHash) return decodeURIComponent(tgFromHash);
} catch (e) {
/* ignore */
}
} }
const q = window.location.search const q = window.location.search
? new URLSearchParams(window.location.search).get("tgWebAppData") ? new URLSearchParams(window.location.search).get("tgWebAppData")
: null; : null;
if (q) { if (q) {
try { return q;
return decodeURIComponent(q);
} catch (e) {
return q;
}
} }
return ""; return "";
} }

View File

@@ -66,6 +66,28 @@ describe("getInitData", () => {
window.location = { ...origLocation, hash: "", search: "" }; window.location = { ...origLocation, hash: "", search: "" };
expect(getInitData()).toBe(""); expect(getInitData()).toBe("");
}); });
it("returns data from hash query param without double-decoding", () => {
window.Telegram = { WebApp: { initData: "" } };
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6&tgWebAppData=user%3Dname%26hash%3Dabc",
search: "",
};
expect(getInitData()).toBe("user=name&hash=abc");
});
it("returns data from search query param without double-decoding", () => {
window.Telegram = { WebApp: { initData: "" } };
delete window.location;
window.location = {
...origLocation,
hash: "",
search: "?tgWebAppData=user%3Dname%26hash%3Dabc",
};
expect(getInitData()).toBe("user=name&hash=abc");
});
}); });
describe("isLocalhost", () => { describe("isLocalhost", () => {

View File

@@ -67,7 +67,7 @@ export const state = {
/** @type {ReturnType<typeof setInterval>|null} */ /** @type {ReturnType<typeof setInterval>|null} */
todayRefreshInterval: null, todayRefreshInterval: null,
/** @type {'ru'|'en'} */ /** @type {'ru'|'en'} */
lang: "ru", lang: "en",
/** One-time bind flag for sticky scroll shadow listener. */ /** One-time bind flag for sticky scroll shadow listener. */
stickyScrollBound: false, stickyScrollBound: false,
/** One-time bind flag for calendar (info button) hint document listeners. */ /** One-time bind flag for calendar (info button) hint document listeners. */

View File

@@ -1,9 +1,7 @@
/** /**
* Internationalization: language detection and translations for webapp. * Internationalization: language from backend config (window.__DT_LANG) and translations.
*/ */
import { getInitData } from "./auth.js";
/** @type {Record<string, Record<string, string>>} */ /** @type {Record<string, Record<string, string>>} */
export const MESSAGES = { export const MESSAGES = {
en: { en: {
@@ -135,34 +133,25 @@ const WEEKDAY_KEYS = [
* @param {string} code - e.g. 'ru', 'en', 'uk' * @param {string} code - e.g. 'ru', 'en', 'uk'
* @returns {'ru'|'en'} * @returns {'ru'|'en'}
*/ */
function normalizeLang(code) { export function normalizeLang(code) {
if (!code || typeof code !== "string") return "ru"; if (!code || typeof code !== "string") return "en";
const lower = code.toLowerCase(); const lower = code.toLowerCase();
if (lower.startsWith("ru")) return "ru"; if (lower.startsWith("ru")) return "ru";
return "en"; return "en";
} }
/** /**
* Detect language: Telegram initData user.language_code → navigator → fallback 'ru'. * Get application language from backend config (window.__DT_LANG).
* Set by /app/config.js from DEFAULT_LANGUAGE. Fallback to 'en' if missing or invalid.
* @returns {'ru'|'en'} * @returns {'ru'|'en'}
*/ */
export function getLang() { export function getLang() {
const initData = getInitData(); const raw =
if (initData) { typeof window !== "undefined" && window.__DT_LANG != null
try { ? String(window.__DT_LANG)
const params = new URLSearchParams(initData); : "";
const userStr = params.get("user"); const lang = normalizeLang(raw);
if (userStr) { return lang === "ru" ? "ru" : "en";
const user = JSON.parse(decodeURIComponent(userStr));
const code = user && user.language_code;
if (code) return normalizeLang(code);
}
} catch (e) {
/* ignore */
}
}
const nav = navigator.language || (navigator.languages && navigator.languages[0]) || "";
return normalizeLang(nav);
} }
/** /**

View File

@@ -1,50 +1,76 @@
/** /**
* Unit tests for i18n: getLang, t (fallback, params), monthName. * Unit tests for i18n: getLang (window.__DT_LANG), normalizeLang, t (fallback, params), monthName.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
const mockGetInitData = vi.fn(); import { getLang, normalizeLang, t, monthName, MESSAGES } from "./i18n.js";
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { getLang, t, monthName, MESSAGES } from "./i18n.js";
describe("getLang", () => { describe("getLang", () => {
const origNavigator = globalThis.navigator; const orig__DT_LANG = globalThis.window?.__DT_LANG;
beforeEach(() => { afterEach(() => {
mockGetInitData.mockReset(); if (typeof globalThis.window !== "undefined") {
if (orig__DT_LANG !== undefined) {
globalThis.window.__DT_LANG = orig__DT_LANG;
} else {
delete globalThis.window.__DT_LANG;
}
}
}); });
it("returns lang from initData user when present", () => { it("returns ru when window.__DT_LANG is ru", () => {
mockGetInitData.mockReturnValue( globalThis.window.__DT_LANG = "ru";
"user=" + encodeURIComponent(JSON.stringify({ language_code: "en" }))
);
expect(getLang()).toBe("en");
});
it("normalizes ru from initData", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "ru" }))
);
expect(getLang()).toBe("ru"); expect(getLang()).toBe("ru");
}); });
it("falls back to navigator.language when initData empty", () => { it("returns en when window.__DT_LANG is en", () => {
mockGetInitData.mockReturnValue(""); globalThis.window.__DT_LANG = "en";
Object.defineProperty(globalThis, "navigator", {
value: { ...origNavigator, language: "en-US", languages: ["en-US", "en"] },
configurable: true,
});
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
it("normalizes to en for unknown language code", () => { it("returns en when window.__DT_LANG is missing", () => {
mockGetInitData.mockReturnValue( delete globalThis.window.__DT_LANG;
"user=" + encodeURIComponent(JSON.stringify({ language_code: "uk" }))
);
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
it("returns en when window.__DT_LANG is invalid (unknown code)", () => {
globalThis.window.__DT_LANG = "uk";
expect(getLang()).toBe("en");
});
it("returns ru when window.__DT_LANG is ru-RU (normalized)", () => {
globalThis.window.__DT_LANG = "ru-RU";
expect(getLang()).toBe("ru");
});
it("returns en when window.__DT_LANG is empty string", () => {
globalThis.window.__DT_LANG = "";
expect(getLang()).toBe("en");
});
it("returns en when window.__DT_LANG is null", () => {
globalThis.window.__DT_LANG = null;
expect(getLang()).toBe("en");
});
});
describe("normalizeLang", () => {
it("returns ru for ru-like codes", () => {
expect(normalizeLang("ru")).toBe("ru");
expect(normalizeLang("ru-RU")).toBe("ru");
});
it("returns en for en and others", () => {
expect(normalizeLang("en")).toBe("en");
expect(normalizeLang("en-US")).toBe("en");
expect(normalizeLang("uk")).toBe("en");
});
it("returns en for empty or invalid", () => {
expect(normalizeLang("")).toBe("en");
expect(normalizeLang(null)).toBe("en");
});
}); });
describe("t", () => { describe("t", () => {

View File

@@ -37,23 +37,35 @@ import {
initTheme(); initTheme();
state.lang = getLang(); state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title"); /**
const loadingEl = getLoadingEl(); * Apply current state.lang to document and locale-dependent UI elements (title, loading, weekdays, nav).
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; * Call at startup and after re-evaluating lang when initData becomes available.
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); * Exported for tests.
const dayLabels = weekdayLabels(state.lang); */
const weekdaysEl = getWeekdaysEl(); export function applyLangToUi() {
if (weekdaysEl) { document.documentElement.lang = state.lang;
const spans = weekdaysEl.querySelectorAll("span"); document.title = t(state.lang, "app.title");
spans.forEach((span, i) => { const loadingEl = getLoadingEl();
if (dayLabels[i]) span.textContent = dayLabels[i]; const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
}); if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
const weekdaysEl = getWeekdaysEl();
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
} }
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn(); applyLangToUi();
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")); window.__dtReady = true;
/** /**
* Run callback when Telegram WebApp is ready (or immediately outside Telegram). * Run callback when Telegram WebApp is ready (or immediately outside Telegram).
@@ -62,9 +74,6 @@ if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"))
*/ */
function runWhenReady(cb) { function runWhenReady(cb) {
if (window.Telegram && window.Telegram.WebApp) { if (window.Telegram && window.Telegram.WebApp) {
if (window.Telegram.WebApp.ready) {
window.Telegram.WebApp.ready();
}
if (window.Telegram.WebApp.expand) { if (window.Telegram.WebApp.expand) {
window.Telegram.WebApp.expand(); window.Telegram.WebApp.expand();
} }
@@ -193,6 +202,8 @@ async function loadMonth() {
setNavEnabled(true); setNavEnabled(true);
return; return;
} }
const errorEl2 = getErrorEl();
if (errorEl2) errorEl2.hidden = true;
const loading = getLoadingEl(); const loading = getLoadingEl();
if (loading) loading.classList.add("hidden"); if (loading) loading.classList.add("hidden");
setNavEnabled(true); setNavEnabled(true);
@@ -276,6 +287,11 @@ function bindStickyScrollShadow() {
runWhenReady(() => { runWhenReady(() => {
requireTelegramOrLocalhost(() => { requireTelegramOrLocalhost(() => {
const newLang = getLang();
if (newLang !== state.lang) {
state.lang = newLang;
applyLangToUi();
}
bindStickyScrollShadow(); bindStickyScrollShadow();
initDayDetail(); initDayDetail();
initHints(); initHints();
@@ -284,7 +300,6 @@ runWhenReady(() => {
window.Telegram.WebApp.initDataUnsafe.start_param) || window.Telegram.WebApp.initDataUnsafe.start_param) ||
""; "";
if (startParam === "duty") { if (startParam === "duty") {
state.lang = getLang();
showCurrentDutyView(() => { showCurrentDutyView(() => {
hideCurrentDutyView(); hideCurrentDutyView();
loadMonth(); loadMonth();

69
webapp/js/main.test.js Normal file
View File

@@ -0,0 +1,69 @@
/**
* Unit tests for main.js: applyLangToUi (locale-to-DOM) and lang re-evaluation behaviour.
*/
import { describe, it, expect, beforeAll } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendarSticky">' +
'<button id="prevMonth"></button><h1 id="monthTitle"></h1><button id="nextMonth"></button>' +
'<div class="weekdays">' +
'<span></span><span></span><span></span><span></span><span></span><span></span><span></span>' +
"</div></div>" +
'<div id="dutyList"></div>' +
'<div id="loading"><span class="loading__text"></span></div>' +
'<div id="error"></div><div id="accessDenied"></div>';
});
import { applyLangToUi } from "./main.js";
import { state } from "./dom.js";
describe("applyLangToUi", () => {
it("state.lang is set from getLang() at startup (getLang reads window.__DT_LANG; default en)", () => {
expect(state.lang).toBe("en");
});
it("sets document title and loading text for en", () => {
state.lang = "en";
applyLangToUi();
expect(document.title).toBe("Duty Calendar");
const loadingText = document.querySelector(".loading__text");
expect(loadingText && loadingText.textContent).toBe("Loading…");
});
it("sets document title and loading text for ru", () => {
state.lang = "ru";
applyLangToUi();
expect(document.title).toBe("Календарь дежурств");
const loadingText = document.querySelector(".loading__text");
expect(loadingText && loadingText.textContent).toBe("Загрузка…");
});
it("sets documentElement.lang to state.lang", () => {
state.lang = "en";
applyLangToUi();
expect(document.documentElement.lang).toBe("en");
state.lang = "ru";
applyLangToUi();
expect(document.documentElement.lang).toBe("ru");
});
it("sets weekday labels and nav aria-labels for current lang", () => {
state.lang = "en";
applyLangToUi();
const weekdays = document.querySelectorAll(".weekdays span");
expect(weekdays.length).toBe(7);
expect(weekdays[0].textContent).toBe("Mon");
const prevBtn = document.getElementById("prevMonth");
const nextBtn = document.getElementById("nextMonth");
expect(prevBtn && prevBtn.getAttribute("aria-label")).toBe("Previous month");
expect(nextBtn && nextBtn.getAttribute("aria-label")).toBe("Next month");
state.lang = "ru";
applyLangToUi();
expect(weekdays[0].textContent).toBe("Пн");
expect(prevBtn && prevBtn.getAttribute("aria-label")).toContain("Пред");
expect(nextBtn && nextBtn.getAttribute("aria-label")).toContain("След");
});
});