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:
@@ -35,7 +35,7 @@ export const state = {
|
||||
current: new Date(), // currently displayed month
|
||||
lastDutiesForList: [], // duties array for the duty list
|
||||
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`.
|
||||
- `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" })`.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
# 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
|
||||
|
||||
# Reject Telegram initData older than this (seconds). 0 = do not check (default).
|
||||
|
||||
@@ -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. |
|
||||
| **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. |
|
||||
| **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
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -58,22 +57,67 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
|
||||
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
|
||||
class NoCacheStaticMiddleware:
|
||||
"""
|
||||
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):
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/app/") and (
|
||||
path.endswith(".js") or path.endswith(".html")
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
def __init__(self, app, **kwargs):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
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.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(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Generator
|
||||
|
||||
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.session import session_scope
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
||||
|
||||
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:
|
||||
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
||||
"""Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
'ru' or 'en'.
|
||||
The header argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
code = _parse_first_language_code(header)
|
||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import time
|
||||
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
|
||||
# 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:
|
||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
||||
"ok", lang).
|
||||
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
|
||||
On success: (user.id, username or None, "ok", lang).
|
||||
"""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
return (None, None, "empty", lang)
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, None, "no_hash", "en")
|
||||
return (None, None, "no_hash", lang)
|
||||
data_pairs = sorted(params.items())
|
||||
# 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)
|
||||
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
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:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
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:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, None, "no_user", "en")
|
||||
return (None, None, "no_user", lang)
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", lang)
|
||||
if not isinstance(user, dict):
|
||||
return (None, None, "user_invalid", "en")
|
||||
lang = normalize_lang(user.get("language_code"))
|
||||
return (None, None, "user_invalid", lang)
|
||||
raw_id = user.get("id")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
|
||||
@@ -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
|
||||
|
||||
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 +11,12 @@ if TYPE_CHECKING:
|
||||
|
||||
def get_lang(user: "User | None") -> str:
|
||||
"""
|
||||
Normalize Telegram user language to 'ru' or 'en'.
|
||||
Uses normalize_lang for user.language_code; when user is None or has no
|
||||
language_code, returns config.DEFAULT_LANGUAGE.
|
||||
Return the application language: always 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 normalize_lang(user.language_code)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
||||
|
||||
@@ -10,24 +10,27 @@ import duty_teller.config as config
|
||||
|
||||
|
||||
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
|
||||
|
||||
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("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):
|
||||
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
|
||||
def test_returns_ru_when_default_language_is_ru(self):
|
||||
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):
|
||||
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"
|
||||
def test_returns_en_when_default_language_is_en(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "en"):
|
||||
assert deps._lang_from_accept_language("ru-RU") == "en"
|
||||
assert deps._lang_from_accept_language(None) == "en"
|
||||
|
||||
|
||||
class TestAuthErrorDetail:
|
||||
|
||||
@@ -23,6 +23,41 @@ def test_health(client):
|
||||
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):
|
||||
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
"""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
|
||||
|
||||
|
||||
def test_get_lang_none_returns_en():
|
||||
assert get_lang(None) == "en"
|
||||
def test_get_lang_always_returns_default_language():
|
||||
"""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():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_ru_when_default_language_is_ru():
|
||||
"""When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
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():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru-RU"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_en_when_default_language_is_en():
|
||||
"""When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "en"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
|
||||
def test_get_lang_en_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = "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"
|
||||
assert core_get_lang(None) == "en"
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert core_get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_t_en_start_greeting():
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""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 (
|
||||
validate_init_data,
|
||||
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():
|
||||
"""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"
|
||||
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
||||
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 username is None
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
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"
|
||||
user = {"first_name": "Test"} # no id
|
||||
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 username is None
|
||||
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():
|
||||
|
||||
@@ -152,6 +152,19 @@
|
||||
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 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -36,15 +36,66 @@
|
||||
<div id="currentDutyView" class="current-duty-view hidden"></div>
|
||||
</div>
|
||||
<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">
|
||||
{
|
||||
"scopes": {
|
||||
"./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 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>
|
||||
</html>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { t } from "./i18n.js";
|
||||
export function buildFetchOptions(initData, externalSignal) {
|
||||
const headers = {};
|
||||
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 timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const onAbort = () => controller.abort();
|
||||
|
||||
@@ -34,23 +34,15 @@ export function getInitData() {
|
||||
if (hash) {
|
||||
const fromHash = getTgWebAppDataFromHash(hash);
|
||||
if (fromHash) return fromHash;
|
||||
try {
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
const tgFromHash = hashParams.get("tgWebAppData");
|
||||
if (tgFromHash) return decodeURIComponent(tgFromHash);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
const tgFromHash = hashParams.get("tgWebAppData");
|
||||
if (tgFromHash) return tgFromHash;
|
||||
}
|
||||
const q = window.location.search
|
||||
? new URLSearchParams(window.location.search).get("tgWebAppData")
|
||||
: null;
|
||||
if (q) {
|
||||
try {
|
||||
return decodeURIComponent(q);
|
||||
} catch (e) {
|
||||
return q;
|
||||
}
|
||||
return q;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -66,6 +66,28 @@ describe("getInitData", () => {
|
||||
window.location = { ...origLocation, hash: "", search: "" };
|
||||
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", () => {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const state = {
|
||||
/** @type {ReturnType<typeof setInterval>|null} */
|
||||
todayRefreshInterval: null,
|
||||
/** @type {'ru'|'en'} */
|
||||
lang: "ru",
|
||||
lang: "en",
|
||||
/** One-time bind flag for sticky scroll shadow listener. */
|
||||
stickyScrollBound: false,
|
||||
/** One-time bind flag for calendar (info button) hint document listeners. */
|
||||
|
||||
@@ -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>>} */
|
||||
export const MESSAGES = {
|
||||
en: {
|
||||
@@ -135,34 +133,25 @@ const WEEKDAY_KEYS = [
|
||||
* @param {string} code - e.g. 'ru', 'en', 'uk'
|
||||
* @returns {'ru'|'en'}
|
||||
*/
|
||||
function normalizeLang(code) {
|
||||
if (!code || typeof code !== "string") return "ru";
|
||||
export function normalizeLang(code) {
|
||||
if (!code || typeof code !== "string") return "en";
|
||||
const lower = code.toLowerCase();
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
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'}
|
||||
*/
|
||||
export function getLang() {
|
||||
const initData = getInitData();
|
||||
if (initData) {
|
||||
try {
|
||||
const params = new URLSearchParams(initData);
|
||||
const userStr = params.get("user");
|
||||
if (userStr) {
|
||||
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);
|
||||
const raw =
|
||||
typeof window !== "undefined" && window.__DT_LANG != null
|
||||
? String(window.__DT_LANG)
|
||||
: "";
|
||||
const lang = normalizeLang(raw);
|
||||
return lang === "ru" ? "ru" : "en";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
|
||||
|
||||
import { getLang, t, monthName, MESSAGES } from "./i18n.js";
|
||||
import { getLang, normalizeLang, t, monthName, MESSAGES } from "./i18n.js";
|
||||
|
||||
describe("getLang", () => {
|
||||
const origNavigator = globalThis.navigator;
|
||||
const orig__DT_LANG = globalThis.window?.__DT_LANG;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetInitData.mockReset();
|
||||
afterEach(() => {
|
||||
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", () => {
|
||||
mockGetInitData.mockReturnValue(
|
||||
"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" }))
|
||||
);
|
||||
it("returns ru when window.__DT_LANG is ru", () => {
|
||||
globalThis.window.__DT_LANG = "ru";
|
||||
expect(getLang()).toBe("ru");
|
||||
});
|
||||
|
||||
it("falls back to navigator.language when initData empty", () => {
|
||||
mockGetInitData.mockReturnValue("");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { ...origNavigator, language: "en-US", languages: ["en-US", "en"] },
|
||||
configurable: true,
|
||||
});
|
||||
it("returns en when window.__DT_LANG is en", () => {
|
||||
globalThis.window.__DT_LANG = "en";
|
||||
expect(getLang()).toBe("en");
|
||||
});
|
||||
|
||||
it("normalizes to en for unknown language code", () => {
|
||||
mockGetInitData.mockReturnValue(
|
||||
"user=" + encodeURIComponent(JSON.stringify({ language_code: "uk" }))
|
||||
);
|
||||
it("returns en when window.__DT_LANG is missing", () => {
|
||||
delete globalThis.window.__DT_LANG;
|
||||
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", () => {
|
||||
|
||||
@@ -37,23 +37,35 @@ import {
|
||||
initTheme();
|
||||
|
||||
state.lang = getLang();
|
||||
document.documentElement.lang = state.lang;
|
||||
document.title = t(state.lang, "app.title");
|
||||
const loadingEl = getLoadingEl();
|
||||
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];
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply current state.lang to document and locale-dependent UI elements (title, loading, weekdays, nav).
|
||||
* Call at startup and after re-evaluating lang when initData becomes available.
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function applyLangToUi() {
|
||||
document.documentElement.lang = state.lang;
|
||||
document.title = t(state.lang, "app.title");
|
||||
const loadingEl = getLoadingEl();
|
||||
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();
|
||||
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
|
||||
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
|
||||
|
||||
applyLangToUi();
|
||||
|
||||
window.__dtReady = true;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (window.Telegram && window.Telegram.WebApp) {
|
||||
if (window.Telegram.WebApp.ready) {
|
||||
window.Telegram.WebApp.ready();
|
||||
}
|
||||
if (window.Telegram.WebApp.expand) {
|
||||
window.Telegram.WebApp.expand();
|
||||
}
|
||||
@@ -193,6 +202,8 @@ async function loadMonth() {
|
||||
setNavEnabled(true);
|
||||
return;
|
||||
}
|
||||
const errorEl2 = getErrorEl();
|
||||
if (errorEl2) errorEl2.hidden = true;
|
||||
const loading = getLoadingEl();
|
||||
if (loading) loading.classList.add("hidden");
|
||||
setNavEnabled(true);
|
||||
@@ -276,6 +287,11 @@ function bindStickyScrollShadow() {
|
||||
|
||||
runWhenReady(() => {
|
||||
requireTelegramOrLocalhost(() => {
|
||||
const newLang = getLang();
|
||||
if (newLang !== state.lang) {
|
||||
state.lang = newLang;
|
||||
applyLangToUi();
|
||||
}
|
||||
bindStickyScrollShadow();
|
||||
initDayDetail();
|
||||
initHints();
|
||||
@@ -284,7 +300,6 @@ runWhenReady(() => {
|
||||
window.Telegram.WebApp.initDataUnsafe.start_param) ||
|
||||
"";
|
||||
if (startParam === "duty") {
|
||||
state.lang = getLang();
|
||||
showCurrentDutyView(() => {
|
||||
hideCurrentDutyView();
|
||||
loadMonth();
|
||||
|
||||
69
webapp/js/main.test.js
Normal file
69
webapp/js/main.test.js
Normal 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("След");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user