feat: add team calendar ICS endpoint and related functionality
All checks were successful
CI / lint-and-test (push) Successful in 23s

- Implemented a new API endpoint to generate an ICS calendar for team duty shifts, accessible via a valid token.
- Enhanced the `calendar_link` command to return both personal and team calendar URLs.
- Added a new function to build the team ICS file, ensuring each event includes the duty holder's name in the description.
- Updated tests to cover the new team calendar functionality, including validation for token formats and response content.
- Revised internationalization messages to reflect the new team calendar links.
This commit is contained in:
2026-02-21 23:41:00 +03:00
parent 0c93fe3372
commit 77a94fa91b
8 changed files with 183 additions and 17 deletions

View File

@@ -268,6 +268,67 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
assert data[0]["full_name"] == "User A"
def test_calendar_ical_team_404_invalid_token_format(client):
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB."""
r = client.get("/api/calendar/ical/team/short.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404."""
mock_get_user.return_value = None
valid_format_token = "B" * 43
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
mock_get_user.assert_called_once()
@patch("duty_teller.api.app.build_team_ics")
@patch("duty_teller.api.app.get_duties")
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_team_200_only_duty_and_description(
mock_get_user, mock_get_duties, mock_build_team_ics, client
):
"""GET /api/calendar/ical/team/{token}.ics returns ICS with duty-only events and DESCRIPTION."""
from types import SimpleNamespace
mock_user = SimpleNamespace(id=1, full_name="User A")
mock_get_user.return_value = mock_user
duty = SimpleNamespace(
id=10,
user_id=1,
start_at="2026-06-15T09:00:00Z",
end_at="2026-06-15T18:00:00Z",
event_type="duty",
)
non_duty = SimpleNamespace(
id=11,
user_id=2,
start_at="2026-06-16T09:00:00Z",
end_at="2026-06-16T18:00:00Z",
event_type="vacation",
)
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
token = "y" * 43
r = client.get(f"/api/calendar/ical/team/{token}.ics")
assert r.status_code == 200
assert r.headers.get("content-type", "").startswith("text/calendar")
assert b"BEGIN:VCALENDAR" in r.content
mock_get_user.assert_called_once()
mock_get_duties.assert_called_once()
# build_team_ics called with only duty (event_type duty), not vacation
mock_build_team_ics.assert_called_once()
duties_arg = mock_build_team_ics.call_args[0][0]
assert len(duties_arg) == 1
assert duties_arg[0][0].event_type == "duty"
assert duties_arg[0][1] == "User A"
def test_calendar_ical_404_invalid_token_format(client):
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
# Token format must be base64url, 4050 chars; short or invalid chars → 404

View File

@@ -202,7 +202,7 @@ async def test_set_phone_result_error_replies_error():
@pytest.mark.asyncio
async def test_calendar_link_with_user_and_token_replies_with_url():
"""calendar_link with allowed user and token -> reply with link."""
"""calendar_link with allowed user and token -> reply with personal and team URLs."""
message = MagicMock()
message.reply_text = AsyncMock()
user = _make_user()
@@ -233,14 +233,15 @@ async def test_calendar_link_with_user_and_token_replies_with_url():
):
with patch("duty_teller.handlers.commands.t") as mock_t:
mock_t.side_effect = lambda lang, key, **kw: (
f"URL: {kw.get('url', '')}"
f"Personal: {kw.get('url_personal', '')} Team: {kw.get('url_team', '')}"
if "success" in key
else "Hint"
)
await calendar_link(update, MagicMock())
message.reply_text.assert_called_once()
call_args = message.reply_text.call_args[0][0]
assert "abc43token" in call_args or "example.com" in call_args
assert "/api/calendar/ical/abc43token.ics" in call_args
assert "/api/calendar/ical/team/abc43token.ics" in call_args
@pytest.mark.asyncio

View File

@@ -4,7 +4,7 @@ from types import SimpleNamespace
from icalendar import Calendar as ICalendar
from duty_teller.api.personal_calendar_ics import build_personal_ics
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
# Minimal Duty-like object for build_personal_ics(duties_with_name: list[tuple[Duty, str]])
@@ -45,6 +45,22 @@ def test_build_personal_ics_one_duty():
assert b"duty-1@duty-teller" in ics or "duty-1@duty-teller" in ics.decode("utf-8")
def test_build_team_ics_vevent_has_description_with_full_name():
"""Team ICS VEVENT includes DESCRIPTION with the duty holder's full_name."""
duties = [
_duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"),
]
ics = build_team_ics(duties)
cal = ICalendar.from_ical(ics)
assert cal is not None
events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 1
ev = events[0]
assert ev.get("summary") == "Duty"
assert ev.get("description") == "Test User"
assert b"Team Calendar" in ics or "Team Calendar" in ics.decode("utf-8")
def test_build_personal_ics_event_types():
"""Unavailable and vacation get correct SUMMARY."""
duties = [