mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-03 19:00:13 -05:00
443a797e2d
Implement comprehensive CalDAV calendar integration to import calendar events as time entries from CalDAV-compatible servers (Zimbra, Nextcloud, ownCloud). Features: - CalDAV client with calendar discovery and event fetching - Automatic calendar discovery from server URL - Import calendar events (VEVENT) as time entries - Project matching from event titles with fallback to default project - Idempotent sync using IntegrationExternalEventLink to prevent duplicates - Per-user integration setup (similar to Google Calendar) - Support for both server URL (with discovery) and direct calendar URL - SSL certificate verification toggle for self-signed certificates - Configurable lookback period for event import Components: - CalDAVCalendarConnector: Main integration connector with sync logic - CalDAVClient: Low-level CalDAV client using PROPFIND/REPORT requests - IntegrationExternalEventLink: Model for tracking imported events (idempotency) - Setup UI: User-friendly form for configuration - Comprehensive validation and error handling - Full test coverage (unit, integration, route tests) - Documentation: Setup guide and troubleshooting Technical details: - Uses icalendar library for parsing VEVENT components - Handles timezone conversion (CalDAV UTC to app local timezone) - Skips all-day events (only imports timed events) - Stores credentials securely (password in access_token, username in extra_data) - Automatic calendar discovery on first sync if only server URL provided Migration: - Adds integration_external_event_links table for sync tracking - Unique constraint on (integration_id, external_uid) prevents duplicates Documentation: - CALDAV_INTEGRATION.md: Complete feature documentation - CALDAV_QUICK_SETUP.md: Step-by-step setup guide with examples Closes feature request for CalDAV/Zimbra integration.
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""
|
|
Tests for CalDAV calendar integration.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
from flask import url_for
|
|
|
|
from app import db
|
|
from app.models import Integration, IntegrationCredential, IntegrationExternalEventLink, TimeEntry, Project, User
|
|
from app.integrations.caldav_calendar import CalDAVCalendarConnector, CalDAVClient, CalDAVCalendar
|
|
from app.services.integration_service import IntegrationService
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user(db_session):
|
|
"""Create a test user"""
|
|
user = User(username="testuser", email="test@example.com", role="admin")
|
|
user.set_password("testpass")
|
|
db_session.add(user)
|
|
db_session.commit()
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def test_project(db_session, test_user):
|
|
"""Create a test project"""
|
|
from app.models import Client
|
|
|
|
client = Client(name="Test Client", email="client@example.com")
|
|
db_session.add(client)
|
|
db_session.commit()
|
|
|
|
project = Project(name="Test Project", client_id=client.id, status="active")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
return project
|
|
|
|
|
|
@pytest.fixture
|
|
def caldav_integration(db_session, test_user, test_project):
|
|
"""Create a CalDAV integration"""
|
|
integration = Integration(
|
|
name="CalDAV Calendar",
|
|
provider="caldav_calendar",
|
|
user_id=test_user.id,
|
|
is_global=False,
|
|
is_active=True,
|
|
config={
|
|
"server_url": "https://mail.example.com/dav",
|
|
"calendar_url": "https://mail.example.com/dav/user@example.com/Calendar/",
|
|
"calendar_name": "My Calendar",
|
|
"default_project_id": test_project.id,
|
|
"sync_direction": "calendar_to_time_tracker",
|
|
"lookback_days": 90,
|
|
"verify_ssl": True,
|
|
},
|
|
)
|
|
db_session.add(integration)
|
|
db_session.commit()
|
|
|
|
credentials = IntegrationCredential(
|
|
integration_id=integration.id,
|
|
access_token="test_password",
|
|
token_type="Basic",
|
|
scope="caldav",
|
|
extra_data={"username": "user@example.com"},
|
|
)
|
|
db_session.add(credentials)
|
|
db_session.commit()
|
|
|
|
return integration
|
|
|
|
|
|
class TestCalDAVClient:
|
|
"""Test CalDAV client functionality"""
|
|
|
|
def test_client_initialization(self):
|
|
"""Test CalDAV client can be initialized"""
|
|
client = CalDAVClient(username="user@example.com", password="pass", verify_ssl=True)
|
|
assert client.username == "user@example.com"
|
|
assert client.password == "pass"
|
|
assert client.verify_ssl is True
|
|
|
|
@patch("app.integrations.caldav_calendar.requests.request")
|
|
def test_discover_calendars(self, mock_request):
|
|
"""Test calendar discovery"""
|
|
# Mock PROPFIND responses
|
|
mock_resp1 = Mock()
|
|
mock_resp1.text = """<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:">
|
|
<d:response>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:current-user-principal>
|
|
<d:href>/dav/user@example.com</d:href>
|
|
</d:current-user-principal>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"""
|
|
mock_resp1.raise_for_status = Mock()
|
|
|
|
mock_resp2 = Mock()
|
|
mock_resp2.text = """<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cs="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<cs:calendar-home-set>
|
|
<d:href>/dav/user@example.com/Calendar/</d:href>
|
|
</cs:calendar-home-set>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"""
|
|
mock_resp2.raise_for_status = Mock()
|
|
|
|
mock_resp3 = Mock()
|
|
mock_resp3.text = """<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cs="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/user@example.com/Calendar/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>My Calendar</d:displayname>
|
|
<d:resourcetype>
|
|
<cs:calendar/>
|
|
</d:resourcetype>
|
|
</d:prop>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>"""
|
|
mock_resp3.raise_for_status = Mock()
|
|
|
|
mock_request.side_effect = [mock_resp1, mock_resp2, mock_resp3]
|
|
|
|
client = CalDAVClient(username="user@example.com", password="pass")
|
|
calendars = client.discover_calendars("https://mail.example.com/dav")
|
|
|
|
assert len(calendars) == 1
|
|
assert calendars[0].name == "My Calendar"
|
|
assert "Calendar" in calendars[0].href
|
|
|
|
|
|
class TestCalDAVConnector:
|
|
"""Test CalDAV connector"""
|
|
|
|
def test_provider_name(self, caldav_integration):
|
|
"""Test provider name"""
|
|
credentials = IntegrationCredential.query.filter_by(integration_id=caldav_integration.id).first()
|
|
connector = CalDAVCalendarConnector(caldav_integration, credentials)
|
|
assert connector.provider_name == "caldav_calendar"
|
|
|
|
def test_get_basic_creds(self, caldav_integration):
|
|
"""Test getting basic credentials"""
|
|
credentials = IntegrationCredential.query.filter_by(integration_id=caldav_integration.id).first()
|
|
connector = CalDAVCalendarConnector(caldav_integration, credentials)
|
|
username, password = connector._get_basic_creds()
|
|
assert username == "user@example.com"
|
|
assert password == "test_password"
|
|
|
|
def test_get_basic_creds_missing(self, caldav_integration):
|
|
"""Test getting credentials when missing"""
|
|
connector = CalDAVCalendarConnector(caldav_integration, None)
|
|
with pytest.raises(ValueError, match="Missing CalDAV credentials"):
|
|
connector._get_basic_creds()
|
|
|
|
@patch("app.integrations.caldav_calendar.CalDAVClient")
|
|
def test_test_connection(self, mock_client_class, caldav_integration):
|
|
"""Test connection testing"""
|
|
mock_client = Mock()
|
|
mock_client.discover_calendars.return_value = [
|
|
CalDAVCalendar(href="https://mail.example.com/dav/Calendar/", name="My Calendar")
|
|
]
|
|
mock_client.fetch_events.return_value = []
|
|
mock_client_class.return_value = mock_client
|
|
|
|
credentials = IntegrationCredential.query.filter_by(integration_id=caldav_integration.id).first()
|
|
connector = CalDAVCalendarConnector(caldav_integration, credentials)
|
|
result = connector.test_connection()
|
|
|
|
assert result["success"] is True
|
|
assert "Connected to CalDAV" in result["message"]
|
|
assert len(result["calendars"]) == 1
|
|
|
|
@patch("app.integrations.caldav_calendar.CalDAVClient")
|
|
def test_sync_data_imports_events(self, mock_client_class, db_session, caldav_integration, test_project):
|
|
"""Test syncing imports calendar events as time entries"""
|
|
# Mock calendar event
|
|
now_utc = datetime.now(timezone.utc)
|
|
event_data = {
|
|
"uid": "test-event-123",
|
|
"summary": "Meeting with Test Project",
|
|
"description": "Important meeting",
|
|
"start": now_utc - timedelta(hours=1),
|
|
"end": now_utc,
|
|
"href": "https://mail.example.com/dav/Calendar/test-event-123.ics",
|
|
}
|
|
|
|
mock_client = Mock()
|
|
mock_client.fetch_events.return_value = [event_data]
|
|
mock_client_class.return_value = mock_client
|
|
|
|
credentials = IntegrationCredential.query.filter_by(integration_id=caldav_integration.id).first()
|
|
connector = CalDAVCalendarConnector(caldav_integration, credentials)
|
|
result = connector.sync_data()
|
|
|
|
assert result["success"] is True
|
|
assert result["imported"] == 1
|
|
assert result["skipped"] == 0
|
|
|
|
# Verify time entry was created
|
|
time_entry = TimeEntry.query.filter_by(user_id=caldav_integration.user_id).first()
|
|
assert time_entry is not None
|
|
assert time_entry.project_id == test_project.id
|
|
assert "Meeting with Test Project" in time_entry.notes
|
|
|
|
# Verify external event link was created
|
|
link = IntegrationExternalEventLink.query.filter_by(
|
|
integration_id=caldav_integration.id, external_uid="test-event-123"
|
|
).first()
|
|
assert link is not None
|
|
assert link.time_entry_id == time_entry.id
|
|
|
|
@patch("app.integrations.caldav_calendar.CalDAVClient")
|
|
def test_sync_data_skips_duplicates(self, mock_client_class, db_session, caldav_integration, test_project):
|
|
"""Test sync skips already imported events"""
|
|
# Create existing link
|
|
time_entry = TimeEntry(
|
|
user_id=caldav_integration.user_id,
|
|
project_id=test_project.id,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now() + timedelta(hours=1),
|
|
notes="Existing entry",
|
|
)
|
|
db_session.add(time_entry)
|
|
db_session.flush()
|
|
|
|
link = IntegrationExternalEventLink(
|
|
integration_id=caldav_integration.id,
|
|
time_entry_id=time_entry.id,
|
|
external_uid="test-event-123",
|
|
)
|
|
db_session.add(link)
|
|
db_session.commit()
|
|
|
|
# Mock same event
|
|
now_utc = datetime.now(timezone.utc)
|
|
event_data = {
|
|
"uid": "test-event-123",
|
|
"summary": "Meeting",
|
|
"description": "",
|
|
"start": now_utc - timedelta(hours=1),
|
|
"end": now_utc,
|
|
"href": "https://mail.example.com/dav/Calendar/test-event-123.ics",
|
|
}
|
|
|
|
mock_client = Mock()
|
|
mock_client.fetch_events.return_value = [event_data]
|
|
mock_client_class.return_value = mock_client
|
|
|
|
credentials = IntegrationCredential.query.filter_by(integration_id=caldav_integration.id).first()
|
|
connector = CalDAVCalendarConnector(caldav_integration, credentials)
|
|
result = connector.sync_data()
|
|
|
|
assert result["success"] is True
|
|
assert result["imported"] == 0
|
|
assert result["skipped"] == 1
|
|
|
|
# Verify no duplicate time entry
|
|
entries = TimeEntry.query.filter_by(user_id=caldav_integration.user_id).all()
|
|
assert len(entries) == 1
|
|
|
|
|
|
class TestCalDAVRoutes:
|
|
"""Test CalDAV routes"""
|
|
|
|
def test_caldav_setup_get(self, authenticated_client, test_user, test_project):
|
|
"""Test CalDAV setup page loads"""
|
|
response = authenticated_client.get(url_for("integrations.caldav_setup"))
|
|
assert response.status_code == 200
|
|
assert b"CalDAV Calendar Setup" in response.data
|
|
|
|
def test_caldav_setup_post(self, authenticated_client, test_user, test_project):
|
|
"""Test CalDAV setup form submission"""
|
|
response = authenticated_client.post(
|
|
url_for("integrations.caldav_setup"),
|
|
data={
|
|
"server_url": "https://mail.example.com/dav",
|
|
"username": "user@example.com",
|
|
"password": "testpass",
|
|
"calendar_url": "https://mail.example.com/dav/user@example.com/Calendar/",
|
|
"calendar_name": "My Calendar",
|
|
"default_project_id": str(test_project.id),
|
|
"lookback_days": "90",
|
|
"verify_ssl": "on",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# Should redirect to view integration
|
|
assert response.status_code in (200, 302)
|
|
|
|
# Verify integration was created
|
|
integration = Integration.query.filter_by(provider="caldav_calendar", user_id=test_user.id).first()
|
|
assert integration is not None
|
|
assert integration.config["server_url"] == "https://mail.example.com/dav"
|
|
assert integration.config["default_project_id"] == test_project.id
|
|
|
|
# Verify credentials were saved
|
|
credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first()
|
|
assert credentials is not None
|
|
assert credentials.access_token == "testpass"
|
|
assert credentials.extra_data["username"] == "user@example.com"
|
|
|
|
|
|
class TestIntegrationExternalEventLink:
|
|
"""Test external event link model"""
|
|
|
|
def test_link_creation(self, db_session, caldav_integration, test_project):
|
|
"""Test creating an external event link"""
|
|
time_entry = TimeEntry(
|
|
user_id=caldav_integration.user_id,
|
|
project_id=test_project.id,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now() + timedelta(hours=1),
|
|
notes="Test entry",
|
|
)
|
|
db_session.add(time_entry)
|
|
db_session.flush()
|
|
|
|
link = IntegrationExternalEventLink(
|
|
integration_id=caldav_integration.id,
|
|
time_entry_id=time_entry.id,
|
|
external_uid="test-uid-123",
|
|
external_href="https://example.com/event.ics",
|
|
)
|
|
db_session.add(link)
|
|
db_session.commit()
|
|
|
|
assert link.id is not None
|
|
assert link.external_uid == "test-uid-123"
|
|
assert link.integration_id == caldav_integration.id
|
|
assert link.time_entry_id == time_entry.id
|
|
|
|
def test_link_unique_constraint(self, db_session, caldav_integration, test_project):
|
|
"""Test unique constraint on integration_id + external_uid"""
|
|
time_entry = TimeEntry(
|
|
user_id=caldav_integration.user_id,
|
|
project_id=test_project.id,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now() + timedelta(hours=1),
|
|
notes="Test entry",
|
|
)
|
|
db_session.add(time_entry)
|
|
db_session.flush()
|
|
|
|
link1 = IntegrationExternalEventLink(
|
|
integration_id=caldav_integration.id,
|
|
time_entry_id=time_entry.id,
|
|
external_uid="test-uid-123",
|
|
)
|
|
db_session.add(link1)
|
|
db_session.commit()
|
|
|
|
# Try to create duplicate
|
|
link2 = IntegrationExternalEventLink(
|
|
integration_id=caldav_integration.id,
|
|
time_entry_id=time_entry.id,
|
|
external_uid="test-uid-123",
|
|
)
|
|
db_session.add(link2)
|
|
with pytest.raises(Exception): # Should raise IntegrityError
|
|
db_session.commit()
|
|
|