feat: add calendar subscription token functionality and ICS generation

- Introduced a new database model for calendar subscription tokens, allowing users to generate unique tokens for accessing their personal calendar.
- Implemented API endpoint to return ICS files containing only the subscribing user's duties, enhancing user experience with personalized calendar access.
- Added utility functions for generating ICS files from user duties, ensuring proper formatting and timezone handling.
- Updated command handlers to support the new calendar link feature, providing users with easy access to their personal calendar subscriptions.
- Included unit tests for the new functionality, ensuring reliability and correctness of token generation and ICS file creation.
This commit is contained in:
2026-02-19 17:04:22 +03:00
parent 4afd0ca5cc
commit dc116270b7
14 changed files with 501 additions and 12 deletions

View File

@@ -248,3 +248,50 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
assert len(data) == 1
assert data[0]["event_type"] == "duty"
assert data[0]["full_name"] == "User A"
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_404_unknown_token(mock_get_user, client):
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
mock_get_user.return_value = None
r = client.get("/api/calendar/ical/unknown-token-xyz.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_personal_ics")
@patch("duty_teller.api.app.get_duties_for_user")
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_200_returns_only_that_users_duties(
mock_get_user, mock_get_duties, mock_build_ics, client
):
"""GET /api/calendar/ical/{token}.ics returns ICS with only the token owner's duties."""
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",
)
mock_get_duties.return_value = [(duty, "User A")]
mock_build_ics.return_value = (
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
)
r = client.get("/api/calendar/ical/valid-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_with(ANY, 1, from_date=ANY, to_date=ANY)
mock_build_ics.assert_called_once()
# Only User A's duty was passed to build_personal_ics
duties_arg = mock_build_ics.call_args[0][0]
assert len(duties_arg) == 1
assert duties_arg[0][0].user_id == 1
assert duties_arg[0][1] == "User A"

View File

@@ -0,0 +1,67 @@
"""Unit tests for calendar subscription token repository functions."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from duty_teller.db.models import Base
from duty_teller.db.repository import (
create_calendar_token,
get_user_by_calendar_token,
get_or_create_user_by_full_name,
)
@pytest.fixture
def session():
"""In-memory SQLite session with all tables (including calendar_subscription_tokens)."""
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()
try:
yield s
finally:
s.close()
@pytest.fixture
def user_a(session):
"""Create one user."""
u = get_or_create_user_by_full_name(session, "User A")
return u
def test_create_calendar_token_returns_token_string(session, user_a):
"""create_calendar_token returns a non-empty URL-safe string."""
token = create_calendar_token(session, user_a.id)
assert isinstance(token, str)
assert len(token) > 0
assert " " not in token
def test_get_user_by_calendar_token_valid_returns_user(session, user_a):
"""Valid token returns the correct user."""
token = create_calendar_token(session, user_a.id)
user = get_user_by_calendar_token(session, token)
assert user is not None
assert user.id == user_a.id
assert user.full_name == "User A"
def test_get_user_by_calendar_token_invalid_returns_none(session, user_a):
"""Invalid or unknown token returns None."""
create_calendar_token(session, user_a.id)
assert get_user_by_calendar_token(session, "wrong-token") is None
assert get_user_by_calendar_token(session, "") is None
def test_create_calendar_token_one_active_per_user(session, user_a):
"""Creating a new token removes the previous one for the same user."""
token1 = create_calendar_token(session, user_a.id)
token2 = create_calendar_token(session, user_a.id)
assert token1 != token2
assert get_user_by_calendar_token(session, token1) is None
assert get_user_by_calendar_token(session, token2).id == user_a.id

View File

@@ -0,0 +1,60 @@
"""Unit tests for personal calendar ICS generation."""
from types import SimpleNamespace
from icalendar import Calendar as ICalendar
from duty_teller.api.personal_calendar_ics import build_personal_ics
# Minimal Duty-like object for build_personal_ics(duties_with_name: list[tuple[Duty, str]])
def _duty(id_, start_at, end_at, event_type="duty"):
return (
SimpleNamespace(
id=id_, start_at=start_at, end_at=end_at, event_type=event_type
),
"Test User",
)
def test_build_personal_ics_empty_returns_valid_calendar():
"""Empty list produces VCALENDAR with no VEVENTs."""
ics = build_personal_ics([])
assert ics.startswith(b"BEGIN:VCALENDAR")
assert ics.rstrip().endswith(b"END:VCALENDAR")
cal = ICalendar.from_ical(ics)
assert cal is not None
events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 0
def test_build_personal_ics_one_duty():
"""One duty produces one VEVENT with correct DTSTART/DTEND/SUMMARY."""
duties = [
_duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"),
]
ics = build_personal_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 "2025-02-20" in str(ev.get("dtstart").dt)
assert "2025-02-20" in str(ev.get("dtend").dt)
assert b"duty-1@duty-teller" in ics or "duty-1@duty-teller" in ics.decode("utf-8")
def test_build_personal_ics_event_types():
"""Unavailable and vacation get correct SUMMARY."""
duties = [
_duty(1, "2025-02-20T09:00:00Z", "2025-02-21T09:00:00Z", "unavailable"),
_duty(2, "2025-02-25T00:00:00Z", "2025-03-01T00:00:00Z", "vacation"),
]
ics = build_personal_ics(duties)
cal = ICalendar.from_ical(ics)
events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 2
summaries = [str(ev.get("summary")) for ev in events]
assert "Unavailable" in summaries
assert "Vacation" in summaries