feat: add team calendar ICS endpoint and related functionality
All checks were successful
CI / lint-and-test (push) Successful in 23s
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:
@@ -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, 40–50 chars; short or invalid chars → 404
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user