Add configuration rules, refactor settings management, and enhance import functionality
- Introduced a new configuration file `.cursorrules` to define coding standards, error handling, testing requirements, and project-specific guidelines. - Refactored `config.py` to implement a `Settings` dataclass for better management of environment variables, improving testability and maintainability. - Updated the import duty schedule handler to utilize session management with `session_scope`, ensuring proper database session handling. - Enhanced the import service to streamline the duty schedule import process, improving code organization and readability. - Added new service layer functions to encapsulate business logic related to group duty pinning and duty schedule imports. - Updated README documentation to reflect the new configuration structure and improved import functionality.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Tests for FastAPI app /api/duties."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -50,7 +50,7 @@ def test_duties_200_when_skip_auth(mock_fetch, client):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")
|
||||
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
||||
|
||||
|
||||
@patch("api.app.validate_init_data_with_reason")
|
||||
@@ -105,7 +105,7 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
assert r.json()[0]["full_name"] == "Иван Иванов"
|
||||
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")
|
||||
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
||||
|
||||
|
||||
def test_duties_e2e_auth_real_validation(client, monkeypatch):
|
||||
@@ -130,7 +130,7 @@ def test_duties_e2e_auth_real_validation(client, monkeypatch):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")
|
||||
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
|
||||
|
||||
|
||||
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
|
||||
@@ -9,7 +9,6 @@ from importers.duty_schedule import (
|
||||
UNAVAILABLE_MARKER,
|
||||
VACATION_MARKER,
|
||||
DutyScheduleParseError,
|
||||
DutyScheduleEntry,
|
||||
parse_duty_schedule,
|
||||
)
|
||||
|
||||
@@ -32,7 +31,11 @@ def test_parse_valid_schedule():
|
||||
assert "Petrov P.P." in by_name
|
||||
# Ivanov: only duty (б, Б, в) -> 2026-02-18, 19, 20
|
||||
ivan = by_name["Ivanov I.I."]
|
||||
assert sorted(ivan.duty_dates) == [date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)]
|
||||
assert sorted(ivan.duty_dates) == [
|
||||
date(2026, 2, 18),
|
||||
date(2026, 2, 19),
|
||||
date(2026, 2, 20),
|
||||
]
|
||||
assert ivan.unavailable_dates == []
|
||||
assert ivan.vacation_dates == []
|
||||
# Petrov: one Н (unavailable), one О (vacation) -> 2026-02-17, 18
|
||||
|
||||
@@ -6,10 +6,13 @@ import pytest
|
||||
|
||||
from db import init_db
|
||||
from db.repository import get_duties
|
||||
from db.session import get_session
|
||||
from importers.duty_schedule import DutyScheduleEntry, DutyScheduleResult, parse_duty_schedule
|
||||
|
||||
from handlers.import_duty_schedule import _run_import
|
||||
from db.session import get_session, session_scope
|
||||
from importers.duty_schedule import (
|
||||
DutyScheduleEntry,
|
||||
DutyScheduleResult,
|
||||
parse_duty_schedule,
|
||||
)
|
||||
from services.import_service import run_import
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -21,6 +24,7 @@ def db_url():
|
||||
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
|
||||
|
||||
session_module._engine = None
|
||||
session_module._SessionLocal = None
|
||||
init_db(db_url)
|
||||
@@ -49,7 +53,8 @@ def test_import_creates_users_and_duties(db_url):
|
||||
),
|
||||
],
|
||||
)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
|
||||
with session_scope(db_url) as session:
|
||||
num_users, num_duty, num_unav, num_vac = run_import(session, result, 6, 0)
|
||||
assert num_users == 2
|
||||
assert num_duty == 3
|
||||
assert num_unav == 0
|
||||
@@ -84,7 +89,8 @@ def test_import_replaces_duties_in_range(db_url):
|
||||
)
|
||||
],
|
||||
)
|
||||
_run_import(db_url, result1, 9, 0)
|
||||
with session_scope(db_url) as session:
|
||||
run_import(session, result1, 9, 0)
|
||||
|
||||
session = get_session(db_url)
|
||||
try:
|
||||
@@ -105,7 +111,8 @@ def test_import_replaces_duties_in_range(db_url):
|
||||
)
|
||||
],
|
||||
)
|
||||
_run_import(db_url, result2, 9, 0)
|
||||
with session_scope(db_url) as session:
|
||||
run_import(session, result2, 9, 0)
|
||||
|
||||
session = get_session(db_url)
|
||||
try:
|
||||
@@ -123,7 +130,8 @@ def test_import_full_flow_parse_then_import(db_url):
|
||||
'"schedule": [{"name": "Alexey A.", "duty": "\u0431; ; \u0432"}]}'
|
||||
).encode("utf-8")
|
||||
parsed = parse_duty_schedule(raw)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, parsed, 6, 0)
|
||||
with session_scope(db_url) as session:
|
||||
num_users, num_duty, num_unav, num_vac = run_import(session, parsed, 6, 0)
|
||||
assert num_users == 1
|
||||
assert num_duty == 2
|
||||
assert num_unav == 0
|
||||
@@ -149,11 +157,16 @@ def test_import_event_types_unavailable_vacation(db_url):
|
||||
full_name="Mixed User",
|
||||
duty_dates=[date(2026, 2, 16)],
|
||||
unavailable_dates=[date(2026, 2, 17)],
|
||||
vacation_dates=[date(2026, 2, 18), date(2026, 2, 19), date(2026, 2, 20)],
|
||||
vacation_dates=[
|
||||
date(2026, 2, 18),
|
||||
date(2026, 2, 19),
|
||||
date(2026, 2, 20),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
|
||||
with session_scope(db_url) as session:
|
||||
num_users, num_duty, num_unav, num_vac = run_import(session, result, 6, 0)
|
||||
assert num_users == 1
|
||||
assert num_duty == 1 and num_unav == 1 and num_vac == 1
|
||||
|
||||
@@ -185,11 +198,16 @@ def test_import_vacation_with_gap_two_periods(db_url):
|
||||
full_name="Vacation User",
|
||||
duty_dates=[],
|
||||
unavailable_dates=[],
|
||||
vacation_dates=[date(2026, 2, 17), date(2026, 2, 18), date(2026, 2, 20)],
|
||||
vacation_dates=[
|
||||
date(2026, 2, 17),
|
||||
date(2026, 2, 18),
|
||||
date(2026, 2, 20),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
num_users, num_duty, num_unav, num_vac = _run_import(db_url, result, 6, 0)
|
||||
with session_scope(db_url) as session:
|
||||
num_users, num_duty, num_unav, num_vac = run_import(session, result, 6, 0)
|
||||
assert num_users == 1
|
||||
assert num_duty == 0 and num_unav == 0 and num_vac == 2
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from db.models import Base, User, Duty
|
||||
from db.models import Base, User
|
||||
from db.repository import (
|
||||
delete_duties_in_range,
|
||||
get_or_create_user_by_full_name,
|
||||
@@ -15,7 +15,9 @@ from db.repository import (
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
s = Session()
|
||||
|
||||
99
tests/test_utils.py
Normal file
99
tests/test_utils.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Unit tests for utils (dates, user, handover)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from 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
|
||||
|
||||
|
||||
# --- dates ---
|
||||
|
||||
|
||||
def test_day_start_iso():
|
||||
assert day_start_iso(date(2026, 2, 18)) == "2026-02-18T00:00:00Z"
|
||||
|
||||
|
||||
def test_day_end_iso():
|
||||
assert day_end_iso(date(2026, 2, 18)) == "2026-02-18T23:59:59Z"
|
||||
|
||||
|
||||
def test_duty_to_iso():
|
||||
assert duty_to_iso(date(2026, 2, 18), 6, 0) == "2026-02-18T06:00:00Z"
|
||||
|
||||
|
||||
def test_parse_iso_date_valid():
|
||||
assert parse_iso_date("2026-02-18") == date(2026, 2, 18)
|
||||
assert parse_iso_date(" 2026-02-18 ") == date(2026, 2, 18)
|
||||
|
||||
|
||||
def test_parse_iso_date_invalid():
|
||||
assert parse_iso_date("") is None
|
||||
assert parse_iso_date("2026-02-31") is None # invalid day
|
||||
assert parse_iso_date("18-02-2026") is None
|
||||
assert parse_iso_date("not-a-date") is None
|
||||
|
||||
|
||||
def test_validate_date_range_ok():
|
||||
validate_date_range("2025-01-01", "2025-01-31") # no raise
|
||||
|
||||
|
||||
def test_validate_date_range_bad_format():
|
||||
with pytest.raises(ValueError, match="формате YYYY-MM-DD"):
|
||||
validate_date_range("01-01-2025", "2025-01-31")
|
||||
with pytest.raises(ValueError, match="формате YYYY-MM-DD"):
|
||||
validate_date_range("2025-01-01", "invalid")
|
||||
|
||||
|
||||
def test_validate_date_range_from_after_to():
|
||||
with pytest.raises(ValueError, match="from не должна быть позже"):
|
||||
validate_date_range("2025-02-01", "2025-01-01")
|
||||
|
||||
|
||||
# --- user ---
|
||||
|
||||
|
||||
def test_build_full_name_both():
|
||||
assert build_full_name("John", "Doe") == "John Doe"
|
||||
|
||||
|
||||
def test_build_full_name_first_only():
|
||||
assert build_full_name("John", None) == "John"
|
||||
|
||||
|
||||
def test_build_full_name_last_only():
|
||||
assert build_full_name(None, "Doe") == "Doe"
|
||||
|
||||
|
||||
def test_build_full_name_empty():
|
||||
assert build_full_name("", "") == "User"
|
||||
assert build_full_name(None, None) == "User"
|
||||
|
||||
|
||||
# --- handover ---
|
||||
|
||||
|
||||
def test_parse_handover_utc():
|
||||
assert parse_handover_time("09:00") == (9, 0)
|
||||
assert parse_handover_time("09:00 UTC") == (9, 0)
|
||||
assert parse_handover_time(" 06:30 ") == (6, 30)
|
||||
|
||||
|
||||
def test_parse_handover_with_tz():
|
||||
# Europe/Moscow UTC+3 in winter: 09:00 Moscow = 06:00 UTC
|
||||
assert parse_handover_time("09:00 Europe/Moscow") == (6, 0)
|
||||
|
||||
|
||||
def test_parse_handover_invalid():
|
||||
assert parse_handover_time("") is None
|
||||
assert parse_handover_time("not a time") is None
|
||||
# 25:00 is normalized to 1:00 by hour % 24; use non-matching string
|
||||
assert parse_handover_time("12") is None
|
||||
Reference in New Issue
Block a user