Refactor project structure and enhance Docker configuration

- Updated `.dockerignore` to exclude test and development artifacts, optimizing the Docker image size.
- Refactored `main.py` to delegate execution to `duty_teller.run.main()`, simplifying the entry point.
- Introduced a new `duty_teller` package to encapsulate core functionality, improving modularity and organization.
- Enhanced `pyproject.toml` to define a script for running the application, streamlining the execution process.
- Updated README documentation to reflect changes in project structure and usage instructions.
- Improved Alembic environment configuration to utilize the new package structure for database migrations.
This commit is contained in:
2026-02-18 13:03:14 +03:00
parent 5331fac334
commit 28973489a5
42 changed files with 361 additions and 363 deletions

33
tests/helpers.py Normal file
View File

@@ -0,0 +1,33 @@
"""Shared test helpers (e.g. make_init_data for Telegram auth tests)."""
import hashlib
import hmac
import json
from urllib.parse import quote, unquote
def make_init_data(
user: dict | None,
bot_token: str,
auth_date: int | None = None,
) -> str:
"""Build initData string with valid HMAC for testing."""
params = {}
if user is not None:
params["user"] = quote(json.dumps(user))
if auth_date is not None:
params["auth_date"] = str(auth_date)
pairs = sorted(params.items())
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in pairs)
secret_key = hmac.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed = hmac.new(
secret_key,
msg=data_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
params["hash"] = computed
return "&".join(f"{k}={v}" for k, v in sorted(params.items()))

View File

@@ -6,9 +6,9 @@ from unittest.mock import ANY, patch
import pytest
from fastapi.testclient import TestClient
import config
from api.app import app
from tests.test_telegram_auth import _make_init_data
import duty_teller.config as config
from duty_teller.api.app import app
from tests.helpers import make_init_data
@pytest.fixture
@@ -28,11 +28,10 @@ def test_duties_from_after_to(client):
assert "from" in r.json()["detail"].lower() or "позже" in r.json()["detail"]
@patch("api.app._is_private_client")
@patch("api.app.config.MINI_APP_SKIP_AUTH", False)
@patch("duty_teller.api.dependencies._is_private_client")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_without_init_data_from_public_client(mock_private, client):
"""Without initData and without private IP / skip-auth, should get 403."""
mock_private.return_value = False # simulate public client
mock_private.return_value = False
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
@@ -40,8 +39,8 @@ def test_duties_403_without_init_data_from_public_client(mock_private, client):
assert r.status_code == 403
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("api.app._fetch_duties_response")
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("duty_teller.api.app.fetch_duties_response")
def test_duties_200_when_skip_auth(mock_fetch, client):
mock_fetch.return_value = []
r = client.get(
@@ -53,7 +52,7 @@ def test_duties_200_when_skip_auth(mock_fetch, client):
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
@patch("api.app.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
def test_duties_403_when_init_data_invalid(mock_validate, client):
mock_validate.return_value = (None, "hash_mismatch")
r = client.get(
@@ -66,12 +65,12 @@ def test_duties_403_when_init_data_invalid(mock_validate, client):
assert "авторизации" in detail or "Неверные" in detail or "Неверная" in detail
@patch("api.app.validate_init_data_with_reason")
@patch("api.app.config.can_access_miniapp")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client):
mock_validate.return_value = ("someuser", "ok")
mock_can_access.return_value = False
with patch("api.app._fetch_duties_response") as mock_fetch:
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
@@ -82,12 +81,12 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl
mock_fetch.assert_not_called()
@patch("api.app.validate_init_data_with_reason")
@patch("api.app.config.can_access_miniapp")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
mock_validate.return_value = ("alloweduser", "ok")
mock_can_access.return_value = True
with patch("api.app._fetch_duties_response") as mock_fetch:
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
mock_fetch.return_value = [
{
"id": 1,
@@ -109,19 +108,18 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
def test_duties_e2e_auth_real_validation(client, monkeypatch):
"""E2E: valid initData + allowlist, no mocks on validate_init_data_with_reason; full auth path."""
test_token = "123:ABC"
test_username = "e2euser"
monkeypatch.setattr(config, "BOT_TOKEN", test_token)
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username})
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
init_data = _make_init_data(
init_data = make_init_data(
{"id": 1, "username": test_username},
test_token,
auth_date=int(time.time()),
)
with patch("api.app._fetch_duties_response") as mock_fetch:
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
mock_fetch.return_value = []
r = client.get(
"/api/duties",
@@ -133,9 +131,8 @@ def test_duties_e2e_auth_real_validation(client, monkeypatch):
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
"""When DB returns duty with event_type not in (duty, unavailable, vacation), API returns 200 with event_type='duty'."""
from types import SimpleNamespace
fake_duty = SimpleNamespace(
@@ -149,7 +146,7 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
def fake_get_duties(session, from_date, to_date):
return [(fake_duty, "User A")]
with patch("api.app.get_duties", side_effect=fake_get_duties):
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},

View File

@@ -1,6 +1,6 @@
"""Tests for config.is_admin and config.can_access_miniapp."""
import config
import duty_teller.config as config
def test_is_admin_true_when_in_admin_list(monkeypatch):

View File

@@ -4,7 +4,7 @@ from datetime import date
import pytest
from importers.duty_schedule import (
from duty_teller.importers.duty_schedule import (
DUTY_MARKERS,
UNAVAILABLE_MARKER,
VACATION_MARKER,

View File

@@ -4,15 +4,15 @@ from datetime import date
import pytest
from db import init_db
from db.repository import get_duties
from db.session import get_session, session_scope
from importers.duty_schedule import (
from duty_teller.db import init_db
from duty_teller.db.repository import get_duties
from duty_teller.db.session import get_session, session_scope
from duty_teller.importers.duty_schedule import (
DutyScheduleEntry,
DutyScheduleResult,
parse_duty_schedule,
)
from services.import_service import run_import
from duty_teller.services.import_service import run_import
@pytest.fixture
@@ -23,7 +23,7 @@ def db_url():
@pytest.fixture(autouse=True)
def _reset_db_session(db_url):
"""Ensure each test uses a fresh engine for :memory: (clear global cache for test URL)."""
import db.session as session_module
import duty_teller.db.session as session_module
session_module._engine = None
session_module._SessionLocal = None

View File

@@ -4,8 +4,8 @@ import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import Base, User
from db.repository import (
from duty_teller.db.models import Base, User
from duty_teller.db.repository import (
delete_duties_in_range,
get_or_create_user_by_full_name,
get_duties,

View File

@@ -1,59 +1,27 @@
"""Tests for api.telegram_auth.validate_init_data."""
"""Tests for duty_teller.api.telegram_auth.validate_init_data."""
import hashlib
import hmac
import json
from urllib.parse import quote, unquote
from api.telegram_auth import validate_init_data
def _make_init_data(
user: dict | None,
bot_token: str,
auth_date: int | None = None,
) -> str:
"""Build initData string with valid HMAC for testing."""
params = {}
if user is not None:
params["user"] = quote(json.dumps(user))
if auth_date is not None:
params["auth_date"] = str(auth_date)
pairs = sorted(params.items())
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in pairs)
secret_key = hmac.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed = hmac.new(
secret_key,
msg=data_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
params["hash"] = computed
return "&".join(f"{k}={v}" for k, v in sorted(params.items()))
from duty_teller.api.telegram_auth import validate_init_data
from tests.helpers import make_init_data
def test_valid_payload_returns_username():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser", "first_name": "Test"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) == "testuser"
def test_valid_payload_username_lowercase():
bot_token = "123:ABC"
user = {"id": 123, "username": "TestUser", "first_name": "Test"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) == "testuser"
def test_invalid_hash_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
# Tamper with hash
init_data = init_data.replace("hash=", "hash=x")
assert validate_init_data(init_data, bot_token) is None
@@ -62,20 +30,20 @@ def test_invalid_hash_returns_none():
def test_wrong_bot_token_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, "other:token") is None
def test_missing_user_returns_none():
bot_token = "123:ABC"
init_data = _make_init_data(None, bot_token) # no user key
init_data = make_init_data(None, bot_token) # no user key
assert validate_init_data(init_data, bot_token) is None
def test_user_without_username_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "first_name": "Test"} # no username
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) is None
@@ -86,7 +54,7 @@ def test_empty_init_data_returns_none():
def test_empty_bot_token_returns_none():
user = {"id": 1, "username": "u"}
init_data = _make_init_data(user, "token")
init_data = make_init_data(user, "token")
assert validate_init_data(init_data, "") is None
@@ -98,7 +66,7 @@ def test_auth_date_expiry_rejects_old_init_data():
user = {"id": 1, "username": "testuser"}
# auth_date 100 seconds ago
old_ts = int(t.time()) - 100
init_data = _make_init_data(user, bot_token, auth_date=old_ts)
init_data = make_init_data(user, bot_token, auth_date=old_ts)
assert validate_init_data(init_data, bot_token, max_age_seconds=60) is None
assert validate_init_data(init_data, bot_token, max_age_seconds=200) == "testuser"
@@ -110,7 +78,7 @@ def test_auth_date_expiry_accepts_fresh_init_data():
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
fresh_ts = int(t.time()) - 10
init_data = _make_init_data(user, bot_token, auth_date=fresh_ts)
init_data = make_init_data(user, bot_token, auth_date=fresh_ts)
assert validate_init_data(init_data, bot_token, max_age_seconds=60) == "testuser"
@@ -118,6 +86,6 @@ def test_auth_date_expiry_requires_auth_date_when_max_age_set():
"""When max_age_seconds is set but auth_date is missing, return None."""
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
init_data = _make_init_data(user, bot_token) # no auth_date
init_data = make_init_data(user, bot_token) # no auth_date
assert validate_init_data(init_data, bot_token, max_age_seconds=86400) is None
assert validate_init_data(init_data, bot_token) == "testuser"

View File

@@ -4,15 +4,15 @@ from datetime import date
import pytest
from utils.dates import (
from duty_teller.utils.dates import (
day_start_iso,
day_end_iso,
duty_to_iso,
parse_iso_date,
validate_date_range,
)
from utils.user import build_full_name
from utils.handover import parse_handover_time
from duty_teller.utils.user import build_full_name
from duty_teller.utils.handover import parse_handover_time
# --- dates ---