Some checks failed
CI / lint-and-test (push) Failing after 27s
- 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.
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""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)
|