Files
duty-teller/tests/test_duty_schedule_parser.py
Nikolay Tatarinov 7ffa727832
Some checks failed
CI / lint-and-test (push) Failing after 27s
feat: enhance error handling and configuration validation
- Added a global exception handler to log unhandled exceptions and return a generic 500 JSON response without exposing details to the client.
- Updated the configuration to validate the `DATABASE_URL` format, ensuring it starts with `sqlite://` or `postgresql://`, and log warnings for invalid formats.
- Introduced safe parsing for numeric environment variables (`HTTP_PORT`, `INIT_DATA_MAX_AGE_SECONDS`) with defaults on invalid values, including logging warnings for out-of-range values.
- Enhanced the duty schedule parser to enforce limits on the number of schedule rows and the length of full names and duty strings, raising appropriate errors when exceeded.
- Updated internationalization messages to include generic error responses for import failures and parsing issues, improving user experience.
- Added unit tests to verify the new error handling and configuration validation behaviors.
2026-03-02 23:36:03 +03:00

159 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for duty-schedule JSON parser."""
import json
from datetime import date
import pytest
from duty_teller.importers.duty_schedule import (
DUTY_MARKERS,
MAX_FULL_NAME_LENGTH,
MAX_SCHEDULE_ROWS,
UNAVAILABLE_MARKER,
VACATION_MARKER,
DutyScheduleParseError,
parse_duty_schedule,
)
def test_parse_valid_schedule():
raw = (
'{"meta": {"start_date": "2026-02-16", "weeks": 2}, '
'"schedule": ['
'{"name": "Ivanov I.I.", "duty": "; ; \u0431 ; \u0411 ; \u0432 ; ;"}, '
'{"name": "Petrov P.P.", "duty": " ; \u041d ; \u041e ; ; ; ;"}'
"]}"
).encode("utf-8")
result = parse_duty_schedule(raw)
assert result.start_date == date(2026, 2, 16)
# Petrov has 7 cells -> end = start + 6
assert result.end_date == date(2026, 2, 22)
assert len(result.entries) == 2
by_name = {e.full_name: e for e in result.entries}
assert "Ivanov I.I." in by_name
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 ivan.unavailable_dates == []
assert ivan.vacation_dates == []
# Petrov: one Н (unavailable), one О (vacation) -> 2026-02-17, 18
petr = by_name["Petrov P.P."]
assert petr.duty_dates == []
assert petr.unavailable_dates == [date(2026, 2, 17)]
assert petr.vacation_dates == [date(2026, 2, 18)]
def test_parse_empty_duty_string():
raw = b'{"meta": {"start_date": "2026-02-01"}, "schedule": [{"name": "A", "duty": ""}]}'
result = parse_duty_schedule(raw)
assert result.start_date == date(2026, 2, 1)
assert result.end_date == date(2026, 2, 1)
assert len(result.entries) == 1
assert result.entries[0].full_name == "A"
assert result.entries[0].duty_dates == []
assert result.entries[0].unavailable_dates == []
assert result.entries[0].vacation_dates == []
def test_parse_invalid_json():
with pytest.raises(DutyScheduleParseError, match="Invalid JSON"):
parse_duty_schedule(b"not json")
def test_parse_missing_meta():
with pytest.raises(DutyScheduleParseError, match="meta"):
parse_duty_schedule(b'{"schedule": []}')
def test_parse_missing_start_date():
with pytest.raises(DutyScheduleParseError, match="start_date"):
parse_duty_schedule(b'{"meta": {"weeks": 1}, "schedule": []}')
def test_parse_invalid_start_date():
with pytest.raises(DutyScheduleParseError, match="start_date|Invalid"):
parse_duty_schedule(b'{"meta": {"start_date": "not-a-date"}, "schedule": []}')
def test_parse_missing_schedule():
with pytest.raises(DutyScheduleParseError, match="schedule"):
parse_duty_schedule(b'{"meta": {"start_date": "2026-02-01"}}')
def test_parse_schedule_not_array():
with pytest.raises(DutyScheduleParseError, match="schedule"):
parse_duty_schedule(b'{"meta": {"start_date": "2026-02-01"}, "schedule": {}}')
def test_parse_schedule_item_missing_name():
with pytest.raises(DutyScheduleParseError, match="name"):
parse_duty_schedule(
b'{"meta": {"start_date": "2026-02-01"}, "schedule": [{"duty": ";"}]}'
)
def test_parse_schedule_item_empty_name():
with pytest.raises(DutyScheduleParseError, match="empty"):
parse_duty_schedule(
b'{"meta": {"start_date": "2026-02-01"}, "schedule": [{"name": " ", "duty": ""}]}'
)
def test_duty_markers():
assert DUTY_MARKERS == frozenset({"б", "Б", "в", "В"})
assert "Н" not in DUTY_MARKERS and "О" not in DUTY_MARKERS
def test_unavailable_and_vacation_markers():
assert UNAVAILABLE_MARKER == "Н"
assert VACATION_MARKER == "О"
raw = (
'{"meta": {"start_date": "2026-02-01"}, "schedule": ['
'{"name": "X", "duty": "\u041d; \u041e; \u0432"}]}'
).encode("utf-8")
result = parse_duty_schedule(raw)
entry = result.entries[0]
assert entry.unavailable_dates == [date(2026, 2, 1)]
assert entry.vacation_dates == [date(2026, 2, 2)]
assert entry.duty_dates == [date(2026, 2, 3)]
def test_parse_start_date_year_out_of_range():
"""start_date year must be current ± 1; otherwise DutyScheduleParseError."""
# Use a year far in the past/future so it fails regardless of test run date.
raw_future = b'{"meta": {"start_date": "2030-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
with pytest.raises(DutyScheduleParseError, match="year|2030"):
parse_duty_schedule(raw_future)
raw_past = b'{"meta": {"start_date": "2019-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
with pytest.raises(DutyScheduleParseError, match="year|2019"):
parse_duty_schedule(raw_past)
def test_parse_too_many_schedule_rows():
"""More than MAX_SCHEDULE_ROWS rows raises DutyScheduleParseError."""
rows = [{"name": f"User {i}", "duty": ""} for i in range(MAX_SCHEDULE_ROWS + 1)]
today = date.today()
start = today.replace(month=1, day=1)
payload = {"meta": {"start_date": start.isoformat()}, "schedule": rows}
raw = json.dumps(payload).encode("utf-8")
with pytest.raises(DutyScheduleParseError, match="too many|max"):
parse_duty_schedule(raw)
def test_parse_full_name_too_long():
"""full_name longer than MAX_FULL_NAME_LENGTH raises DutyScheduleParseError."""
long_name = "A" * (MAX_FULL_NAME_LENGTH + 1)
today = date.today()
start = today.replace(month=1, day=1)
raw = (
f'{{"meta": {{"start_date": "{start.isoformat()}"}}, '
f'"schedule": [{{"name": "{long_name}", "duty": ""}}]}}'
).encode("utf-8")
with pytest.raises(DutyScheduleParseError, match="exceed|character"):
parse_duty_schedule(raw)