iflow-mcp_democratize-technology-chronos-mcp 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- tests/unit/test_validation.py +983 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for Chronos MCP models
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import pytz
|
|
9
|
+
|
|
10
|
+
from chronos_mcp.models import (
|
|
11
|
+
Account,
|
|
12
|
+
AccountStatus,
|
|
13
|
+
Attendee,
|
|
14
|
+
AttendeeRole,
|
|
15
|
+
AttendeeStatus,
|
|
16
|
+
Calendar,
|
|
17
|
+
Event,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestAccount:
|
|
22
|
+
def test_account_creation(self, sample_account):
|
|
23
|
+
"""Test creating an account model"""
|
|
24
|
+
assert sample_account.alias == "test_account"
|
|
25
|
+
assert sample_account.username == "testuser"
|
|
26
|
+
assert sample_account.password == "testpass"
|
|
27
|
+
assert sample_account.status == AccountStatus.UNKNOWN
|
|
28
|
+
|
|
29
|
+
def test_account_without_password(self):
|
|
30
|
+
"""Test creating account without password"""
|
|
31
|
+
account = Account(
|
|
32
|
+
alias="no_pass", url="https://caldav.example.com", username="user"
|
|
33
|
+
)
|
|
34
|
+
assert account.password is None
|
|
35
|
+
|
|
36
|
+
def test_account_url_validation(self):
|
|
37
|
+
"""Test URL validation"""
|
|
38
|
+
with pytest.raises(ValueError):
|
|
39
|
+
Account(alias="bad_url", url="not-a-url", username="user")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestCalendar:
|
|
43
|
+
def test_calendar_creation(self, sample_calendar):
|
|
44
|
+
"""Test creating a calendar model"""
|
|
45
|
+
assert sample_calendar.uid == "cal-123"
|
|
46
|
+
assert sample_calendar.name == "Test Calendar"
|
|
47
|
+
assert sample_calendar.color == "#FF0000"
|
|
48
|
+
assert not sample_calendar.read_only
|
|
49
|
+
|
|
50
|
+
def test_calendar_minimal(self):
|
|
51
|
+
"""Test calendar with minimal fields"""
|
|
52
|
+
cal = Calendar(uid="minimal", name="Minimal Calendar", account_alias="test")
|
|
53
|
+
assert cal.description is None
|
|
54
|
+
assert cal.color is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestEvent:
|
|
58
|
+
def test_event_creation(self, sample_event):
|
|
59
|
+
"""Test creating event"""
|
|
60
|
+
assert sample_event.summary == "Test Event"
|
|
61
|
+
assert sample_event.all_day is False
|
|
62
|
+
assert sample_event.attendees == []
|
|
63
|
+
|
|
64
|
+
def test_all_day_event(self):
|
|
65
|
+
"""Test all-day event creation"""
|
|
66
|
+
event = Event(
|
|
67
|
+
uid="all-day-123",
|
|
68
|
+
summary="All Day Event",
|
|
69
|
+
start=datetime(2025, 7, 5, tzinfo=pytz.UTC),
|
|
70
|
+
end=datetime(2025, 7, 6, tzinfo=pytz.UTC),
|
|
71
|
+
all_day=True,
|
|
72
|
+
calendar_uid="cal-123",
|
|
73
|
+
account_alias="test",
|
|
74
|
+
)
|
|
75
|
+
assert event.all_day is True
|
|
76
|
+
|
|
77
|
+
def test_event_with_attendees(self):
|
|
78
|
+
"""Test event with attendees"""
|
|
79
|
+
attendee = Attendee(
|
|
80
|
+
email="attendee@example.com",
|
|
81
|
+
name="Test Attendee",
|
|
82
|
+
role=AttendeeRole.REQ_PARTICIPANT,
|
|
83
|
+
status=AttendeeStatus.ACCEPTED,
|
|
84
|
+
)
|
|
85
|
+
event = Event(
|
|
86
|
+
uid="meeting-123",
|
|
87
|
+
summary="Meeting",
|
|
88
|
+
start=datetime.now(pytz.UTC),
|
|
89
|
+
end=datetime.now(pytz.UTC),
|
|
90
|
+
attendees=[attendee],
|
|
91
|
+
calendar_uid="cal-123",
|
|
92
|
+
account_alias="test",
|
|
93
|
+
)
|
|
94
|
+
assert len(event.attendees) == 1
|
|
95
|
+
assert event.attendees[0].email == "attendee@example.com"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for concurrency and race conditions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from unittest.mock import Mock, patch
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from chronos_mcp.accounts import AccountManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestRaceConditions:
|
|
14
|
+
"""Test concurrent access patterns"""
|
|
15
|
+
|
|
16
|
+
@patch("chronos_mcp.accounts.DAVClient")
|
|
17
|
+
def test_concurrent_connection_requests_no_duplicate_disconnect(
|
|
18
|
+
self, mock_dav_client, mock_config_manager, sample_account
|
|
19
|
+
):
|
|
20
|
+
"""Test that staleness check happens inside lock (TOCTOU prevention)
|
|
21
|
+
|
|
22
|
+
Race condition scenario WITHOUT fix:
|
|
23
|
+
1. Thread A checks _is_connection_stale (True) at line 320 OUTSIDE lock
|
|
24
|
+
2. Thread B acquires lock at line 331, creates fresh connection
|
|
25
|
+
3. Thread A acquires lock, checks stale at line 323 INSIDE lock (still True from step 1)
|
|
26
|
+
4. Thread A disconnects fresh connection from step 2
|
|
27
|
+
5. Thread A reconnects, but data race has occurred
|
|
28
|
+
|
|
29
|
+
WITH fix: staleness check must happen INSIDE lock
|
|
30
|
+
"""
|
|
31
|
+
mock_config_manager.add_account(sample_account)
|
|
32
|
+
mgr = AccountManager(mock_config_manager)
|
|
33
|
+
mgr._connection_ttl_minutes = 0.001 # Very short TTL
|
|
34
|
+
|
|
35
|
+
# Mock connection
|
|
36
|
+
call_count = [0]
|
|
37
|
+
def create_mock_client(*args, **kwargs):
|
|
38
|
+
call_count[0] += 1
|
|
39
|
+
mock_client = Mock()
|
|
40
|
+
mock_client.principal.return_value = Mock()
|
|
41
|
+
# Add delay to increase race window
|
|
42
|
+
time.sleep(0.01)
|
|
43
|
+
return mock_client
|
|
44
|
+
|
|
45
|
+
mock_dav_client.side_effect = create_mock_client
|
|
46
|
+
|
|
47
|
+
# Create initial connection and make it stale
|
|
48
|
+
mgr.connect_account("test_account")
|
|
49
|
+
mgr._connection_timestamps["test_account"] = time.time() - 60
|
|
50
|
+
|
|
51
|
+
# Track disconnect calls with timing
|
|
52
|
+
disconnect_times = []
|
|
53
|
+
connect_times = []
|
|
54
|
+
original_disconnect = mgr.disconnect_account
|
|
55
|
+
original_connect = mgr.connect_account
|
|
56
|
+
|
|
57
|
+
def tracked_disconnect(alias):
|
|
58
|
+
disconnect_times.append(time.time())
|
|
59
|
+
return original_disconnect(alias)
|
|
60
|
+
|
|
61
|
+
def tracked_connect(alias):
|
|
62
|
+
connect_times.append(time.time())
|
|
63
|
+
return original_connect(alias)
|
|
64
|
+
|
|
65
|
+
mgr.disconnect_account = tracked_disconnect
|
|
66
|
+
mgr.connect_account = tracked_connect
|
|
67
|
+
|
|
68
|
+
# Force race: threads check stale outside lock
|
|
69
|
+
barrier = threading.Barrier(3) # Synchronize 3 threads
|
|
70
|
+
|
|
71
|
+
def get_conn_with_timing():
|
|
72
|
+
barrier.wait() # All threads start simultaneously
|
|
73
|
+
mgr.get_connection("test_account")
|
|
74
|
+
|
|
75
|
+
threads = [threading.Thread(target=get_conn_with_timing) for _ in range(3)]
|
|
76
|
+
for t in threads:
|
|
77
|
+
t.start()
|
|
78
|
+
for t in threads:
|
|
79
|
+
t.join()
|
|
80
|
+
|
|
81
|
+
# With race condition: multiple disconnects could happen
|
|
82
|
+
# Proper fix: staleness check inside lock prevents this
|
|
83
|
+
# This test documents the issue even if hard to trigger reliably
|
|
84
|
+
assert len(disconnect_times) <= 1, f"Disconnect called {len(disconnect_times)} times - race detected"
|
|
85
|
+
|
|
86
|
+
@patch("chronos_mcp.accounts.DAVClient")
|
|
87
|
+
def test_connection_staleness_check_under_lock(
|
|
88
|
+
self, mock_dav_client, mock_config_manager, sample_account
|
|
89
|
+
):
|
|
90
|
+
"""Test that staleness check happens inside lock to prevent TOCTOU"""
|
|
91
|
+
mock_config_manager.add_account(sample_account)
|
|
92
|
+
mgr = AccountManager(mock_config_manager)
|
|
93
|
+
|
|
94
|
+
mock_client = Mock()
|
|
95
|
+
mock_dav_client.return_value = mock_client
|
|
96
|
+
mock_principal = Mock()
|
|
97
|
+
mock_client.principal.return_value = mock_principal
|
|
98
|
+
|
|
99
|
+
# Connect initially
|
|
100
|
+
mgr.connect_account("test_account")
|
|
101
|
+
|
|
102
|
+
# Make connection just barely not stale
|
|
103
|
+
mgr._connection_timestamps["test_account"] = time.time() - (mgr._connection_ttl_minutes * 60 - 1)
|
|
104
|
+
|
|
105
|
+
# Concurrent access shouldn't cause issues
|
|
106
|
+
threads = [threading.Thread(target=lambda: mgr.get_connection("test_account")) for _ in range(5)]
|
|
107
|
+
for t in threads:
|
|
108
|
+
t.start()
|
|
109
|
+
for t in threads:
|
|
110
|
+
t.join()
|
|
111
|
+
|
|
112
|
+
# Should still have exactly one connection
|
|
113
|
+
assert "test_account" in mgr.connections
|
|
114
|
+
assert mgr.connections["test_account"] is not None
|
|
115
|
+
|
|
116
|
+
@patch("chronos_mcp.accounts.DAVClient")
|
|
117
|
+
def test_get_principal_concurrent_no_duplicate_disconnect(
|
|
118
|
+
self, mock_dav_client, mock_config_manager, sample_account
|
|
119
|
+
):
|
|
120
|
+
"""Test that get_principal() prevents TOCTOU race same as get_connection()"""
|
|
121
|
+
mock_config_manager.add_account(sample_account)
|
|
122
|
+
mgr = AccountManager(mock_config_manager)
|
|
123
|
+
mgr._connection_ttl_minutes = 0.001
|
|
124
|
+
|
|
125
|
+
# Mock connection
|
|
126
|
+
def create_mock_client(*args, **kwargs):
|
|
127
|
+
mock_client = Mock()
|
|
128
|
+
mock_client.principal.return_value = Mock()
|
|
129
|
+
time.sleep(0.01) # Increase race window
|
|
130
|
+
return mock_client
|
|
131
|
+
|
|
132
|
+
mock_dav_client.side_effect = create_mock_client
|
|
133
|
+
|
|
134
|
+
# Create initial connection and make it stale
|
|
135
|
+
mgr.connect_account("test_account")
|
|
136
|
+
mgr._connection_timestamps["test_account"] = time.time() - 60
|
|
137
|
+
|
|
138
|
+
# Track disconnect calls
|
|
139
|
+
disconnect_times = []
|
|
140
|
+
original_disconnect = mgr.disconnect_account
|
|
141
|
+
|
|
142
|
+
def tracked_disconnect(alias):
|
|
143
|
+
disconnect_times.append(time.time())
|
|
144
|
+
return original_disconnect(alias)
|
|
145
|
+
|
|
146
|
+
mgr.disconnect_account = tracked_disconnect
|
|
147
|
+
|
|
148
|
+
# Force race with synchronized start
|
|
149
|
+
barrier = threading.Barrier(3)
|
|
150
|
+
|
|
151
|
+
def get_principal_with_timing():
|
|
152
|
+
barrier.wait() # All threads start simultaneously
|
|
153
|
+
mgr.get_principal("test_account")
|
|
154
|
+
|
|
155
|
+
threads = [threading.Thread(target=get_principal_with_timing) for _ in range(3)]
|
|
156
|
+
for t in threads:
|
|
157
|
+
t.start()
|
|
158
|
+
for t in threads:
|
|
159
|
+
t.join()
|
|
160
|
+
|
|
161
|
+
# Should only disconnect once during reconnect
|
|
162
|
+
assert len(disconnect_times) <= 1, f"Disconnect called {len(disconnect_times)} times - race detected"
|
|
163
|
+
|
|
164
|
+
@patch("chronos_mcp.accounts.DAVClient")
|
|
165
|
+
def test_mixed_connection_principal_access(
|
|
166
|
+
self, mock_dav_client, mock_config_manager, sample_account
|
|
167
|
+
):
|
|
168
|
+
"""Test concurrent get_connection() and get_principal() don't interfere"""
|
|
169
|
+
mock_config_manager.add_account(sample_account)
|
|
170
|
+
mgr = AccountManager(mock_config_manager)
|
|
171
|
+
|
|
172
|
+
mock_client = Mock()
|
|
173
|
+
mock_dav_client.return_value = mock_client
|
|
174
|
+
mock_principal = Mock()
|
|
175
|
+
mock_client.principal.return_value = mock_principal
|
|
176
|
+
|
|
177
|
+
results = {"connection": [], "principal": []}
|
|
178
|
+
|
|
179
|
+
def get_conn():
|
|
180
|
+
conn = mgr.get_connection("test_account")
|
|
181
|
+
results["connection"].append(conn is not None)
|
|
182
|
+
|
|
183
|
+
def get_princ():
|
|
184
|
+
princ = mgr.get_principal("test_account")
|
|
185
|
+
results["principal"].append(princ is not None)
|
|
186
|
+
|
|
187
|
+
# Mix of connection and principal requests
|
|
188
|
+
threads = []
|
|
189
|
+
for i in range(10):
|
|
190
|
+
if i % 2 == 0:
|
|
191
|
+
threads.append(threading.Thread(target=get_conn))
|
|
192
|
+
else:
|
|
193
|
+
threads.append(threading.Thread(target=get_princ))
|
|
194
|
+
|
|
195
|
+
for t in threads:
|
|
196
|
+
t.start()
|
|
197
|
+
for t in threads:
|
|
198
|
+
t.join()
|
|
199
|
+
|
|
200
|
+
# All requests should succeed
|
|
201
|
+
assert all(results["connection"]), "Some connection requests failed"
|
|
202
|
+
assert all(results["principal"]), "Some principal requests failed"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Integration tests for recurring event functionality."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from chronos_mcp.models import Event
|
|
9
|
+
|
|
10
|
+
# Import the actual function directly
|
|
11
|
+
from chronos_mcp.server import create_recurring_event
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestRecurringEventIntegration:
|
|
15
|
+
"""Test recurring event MCP tools integration."""
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_create_recurring_event_success(self):
|
|
19
|
+
"""Test successful creation of recurring event."""
|
|
20
|
+
# Mock the event manager
|
|
21
|
+
mock_event = Event(
|
|
22
|
+
uid="event-123",
|
|
23
|
+
summary="Weekly Team Meeting",
|
|
24
|
+
start=datetime.now(timezone.utc),
|
|
25
|
+
end=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
26
|
+
all_day=False,
|
|
27
|
+
calendar_uid="cal-456",
|
|
28
|
+
recurrence_rule="FREQ=WEEKLY;BYDAY=MO;COUNT=10",
|
|
29
|
+
account_alias="default",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
with patch(
|
|
33
|
+
"chronos_mcp.server.event_manager.create_event", return_value=mock_event
|
|
34
|
+
):
|
|
35
|
+
# Direct function call
|
|
36
|
+
result = await create_recurring_event.fn(
|
|
37
|
+
calendar_uid="cal-456",
|
|
38
|
+
summary="Weekly Team Meeting",
|
|
39
|
+
start=datetime.now(timezone.utc).isoformat(),
|
|
40
|
+
duration_minutes=60,
|
|
41
|
+
recurrence_rule="FREQ=WEEKLY;BYDAY=MO;COUNT=10",
|
|
42
|
+
description="Weekly sync meeting",
|
|
43
|
+
location=None,
|
|
44
|
+
alarm_minutes=None,
|
|
45
|
+
attendees_json=None,
|
|
46
|
+
account=None,
|
|
47
|
+
)
|
|
48
|
+
assert result["success"] is True
|
|
49
|
+
assert result["event"]["uid"] == "event-123"
|
|
50
|
+
assert result["event"]["summary"] == "Weekly Team Meeting"
|
|
51
|
+
assert result["event"]["recurrence_rule"] == "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_create_recurring_event_invalid_rrule(self):
|
|
55
|
+
"""Test creation fails with invalid RRULE."""
|
|
56
|
+
# Direct function call
|
|
57
|
+
result = await create_recurring_event.fn(
|
|
58
|
+
calendar_uid="cal-456",
|
|
59
|
+
summary="Invalid Event",
|
|
60
|
+
start=datetime.now(timezone.utc).isoformat(),
|
|
61
|
+
duration_minutes=60,
|
|
62
|
+
recurrence_rule="FREQ=DAILY", # Missing COUNT or UNTIL
|
|
63
|
+
description=None,
|
|
64
|
+
location=None,
|
|
65
|
+
alarm_minutes=None,
|
|
66
|
+
attendees_json=None,
|
|
67
|
+
account=None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert result["success"] is False
|
|
71
|
+
assert "must have COUNT or UNTIL" in result["error"]
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_create_recurring_event_count_too_high(self):
|
|
75
|
+
"""Test creation fails when COUNT exceeds limit."""
|
|
76
|
+
# Direct function call
|
|
77
|
+
result = await create_recurring_event.fn(
|
|
78
|
+
calendar_uid="cal-456",
|
|
79
|
+
summary="Too Many Events",
|
|
80
|
+
start=datetime.now(timezone.utc).isoformat(),
|
|
81
|
+
duration_minutes=30,
|
|
82
|
+
recurrence_rule="FREQ=DAILY;COUNT=500", # Exceeds MAX_COUNT
|
|
83
|
+
description=None,
|
|
84
|
+
location=None,
|
|
85
|
+
alarm_minutes=None,
|
|
86
|
+
attendees_json=None,
|
|
87
|
+
account=None,
|
|
88
|
+
)
|
|
89
|
+
assert result["success"] is False
|
|
90
|
+
assert "cannot exceed 365" in result["error"]
|
|
91
|
+
|
|
92
|
+
# NOTE: get_recurring_instances function does not exist in server.py
|
|
93
|
+
# These tests are commented out until the function is implemented
|
|
94
|
+
# @pytest.mark.asyncio
|
|
95
|
+
# async def test_get_recurring_instances_success(self):
|
|
96
|
+
# """Test successful retrieval of recurring instances."""
|
|
97
|
+
# # Mock event with recurrence
|
|
98
|
+
# mock_event = Event(
|
|
99
|
+
# uid="event-123",
|
|
100
|
+
# summary="Daily Standup",
|
|
101
|
+
# start=datetime.now(timezone.utc).replace(hour=9, minute=0, second=0, microsecond=0),
|
|
102
|
+
# end=datetime.now(timezone.utc).replace(hour=9, minute=15, second=0, microsecond=0),
|
|
103
|
+
# all_day=False,
|
|
104
|
+
# calendar_uid="cal-456",
|
|
105
|
+
# recurrence_rule="FREQ=DAILY;COUNT=5"
|
|
106
|
+
# )
|
|
107
|
+
#
|
|
108
|
+
# with patch('chronos_mcp.server.event_manager.get_event', return_value=mock_event):
|
|
109
|
+
# start_date = datetime.now(timezone.utc).isoformat()
|
|
110
|
+
# end_date = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
|
111
|
+
#
|
|
112
|
+
# result = await get_recurring_instances(
|
|
113
|
+
# calendar_uid="cal-456",
|
|
114
|
+
# event_uid="event-123",
|
|
115
|
+
# start_date=start_date,
|
|
116
|
+
# end_date=end_date
|
|
117
|
+
# )
|
|
118
|
+
#
|
|
119
|
+
# assert result["success"] is True
|
|
120
|
+
# assert "instances" in result
|
|
121
|
+
# assert len(result["instances"]) <= 5 # Limited by COUNT=5
|
|
122
|
+
# assert result["recurrence_rule"] == "FREQ=DAILY;COUNT=5"
|
|
123
|
+
# # Verify instance structure
|
|
124
|
+
# if result["instances"]:
|
|
125
|
+
# instance = result["instances"][0]
|
|
126
|
+
# assert "start" in instance
|
|
127
|
+
# assert "end" in instance
|
|
128
|
+
# assert "summary" in instance
|
|
129
|
+
# assert instance["summary"] == "Daily Standup"
|
|
130
|
+
# assert instance["original_event_uid"] == "event-123"
|
|
131
|
+
|
|
132
|
+
# @pytest.mark.asyncio
|
|
133
|
+
# async def test_get_recurring_instances_non_recurring(self):
|
|
134
|
+
# """Test error when event is not recurring."""
|
|
135
|
+
# # Mock non-recurring event
|
|
136
|
+
# mock_event = Event(
|
|
137
|
+
# uid="event-123",
|
|
138
|
+
# summary="Single Event",
|
|
139
|
+
# start=datetime.now(timezone.utc),
|
|
140
|
+
# end=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
141
|
+
# all_day=False,
|
|
142
|
+
# calendar_uid="cal-456",
|
|
143
|
+
# recurrence_rule=None # No recurrence
|
|
144
|
+
# )
|
|
145
|
+
#
|
|
146
|
+
# with patch('chronos_mcp.server.event_manager.get_event', return_value=mock_event):
|
|
147
|
+
# result = await get_recurring_instances(
|
|
148
|
+
# calendar_uid="cal-456",
|
|
149
|
+
# event_uid="event-123",
|
|
150
|
+
# start_date=datetime.now(timezone.utc).isoformat(),
|
|
151
|
+
# end_date=(datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
|
152
|
+
# )
|
|
153
|
+
#
|
|
154
|
+
# assert result["success"] is False
|
|
155
|
+
# assert "not a recurring event" in result["error"]
|
|
156
|
+
# assert result["error_code"] == "VALIDATION_ERROR"
|
tests/unit/test_rrule.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Unit tests for RRULE validation and parsing."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
from chronos_mcp.rrule import MAX_COUNT, MAX_YEARS_AHEAD, RRuleTemplates, RRuleValidator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestRRuleValidator:
|
|
9
|
+
"""Test RRULE validation logic."""
|
|
10
|
+
|
|
11
|
+
def test_valid_daily_with_count(self):
|
|
12
|
+
"""Test valid daily recurrence with count."""
|
|
13
|
+
is_valid, error = RRuleValidator.validate_rrule("FREQ=DAILY;COUNT=10")
|
|
14
|
+
assert is_valid is True
|
|
15
|
+
assert error is None
|
|
16
|
+
|
|
17
|
+
def test_valid_weekly_with_until(self):
|
|
18
|
+
"""Test valid weekly recurrence with until date."""
|
|
19
|
+
until_date = datetime.now(timezone.utc) + timedelta(days=30)
|
|
20
|
+
rrule = f"FREQ=WEEKLY;UNTIL={until_date.strftime('%Y%m%dT%H%M%SZ')}"
|
|
21
|
+
is_valid, error = RRuleValidator.validate_rrule(rrule)
|
|
22
|
+
assert is_valid is True
|
|
23
|
+
assert error is None
|
|
24
|
+
|
|
25
|
+
def test_valid_monthly_with_bymonthday(self):
|
|
26
|
+
"""Test valid monthly recurrence on specific day."""
|
|
27
|
+
is_valid, error = RRuleValidator.validate_rrule(
|
|
28
|
+
"FREQ=MONTHLY;BYMONTHDAY=15;COUNT=12"
|
|
29
|
+
)
|
|
30
|
+
assert is_valid is True
|
|
31
|
+
assert error is None
|
|
32
|
+
|
|
33
|
+
def test_valid_yearly_recurrence(self):
|
|
34
|
+
"""Test valid yearly recurrence."""
|
|
35
|
+
is_valid, error = RRuleValidator.validate_rrule("FREQ=YEARLY;COUNT=5")
|
|
36
|
+
assert is_valid is True
|
|
37
|
+
assert error is None
|
|
38
|
+
|
|
39
|
+
def test_invalid_no_frequency(self):
|
|
40
|
+
"""Test invalid RRULE without frequency."""
|
|
41
|
+
is_valid, error = RRuleValidator.validate_rrule("COUNT=10")
|
|
42
|
+
assert is_valid is False
|
|
43
|
+
assert "must start with FREQ=" in error
|
|
44
|
+
|
|
45
|
+
def test_invalid_no_end_condition(self):
|
|
46
|
+
"""Test invalid RRULE without COUNT or UNTIL."""
|
|
47
|
+
is_valid, error = RRuleValidator.validate_rrule("FREQ=DAILY")
|
|
48
|
+
assert is_valid is False
|
|
49
|
+
assert "must have COUNT or UNTIL" in error
|
|
50
|
+
|
|
51
|
+
def test_invalid_frequency(self):
|
|
52
|
+
"""Test invalid frequency (SECONDLY not allowed)."""
|
|
53
|
+
is_valid, error = RRuleValidator.validate_rrule("FREQ=SECONDLY;COUNT=10")
|
|
54
|
+
assert is_valid is False
|
|
55
|
+
assert "not allowed" in error
|
|
56
|
+
|
|
57
|
+
def test_invalid_count_too_high(self):
|
|
58
|
+
"""Test invalid COUNT exceeding maximum."""
|
|
59
|
+
is_valid, error = RRuleValidator.validate_rrule(
|
|
60
|
+
f"FREQ=DAILY;COUNT={MAX_COUNT + 1}"
|
|
61
|
+
)
|
|
62
|
+
assert is_valid is False
|
|
63
|
+
assert f"cannot exceed {MAX_COUNT}" in error
|
|
64
|
+
|
|
65
|
+
def test_invalid_count_zero(self):
|
|
66
|
+
"""Test invalid COUNT of zero."""
|
|
67
|
+
is_valid, error = RRuleValidator.validate_rrule("FREQ=DAILY;COUNT=0")
|
|
68
|
+
assert is_valid is False
|
|
69
|
+
assert "must be at least 1" in error
|
|
70
|
+
|
|
71
|
+
def test_invalid_until_too_far(self):
|
|
72
|
+
"""Test invalid UNTIL date too far in future."""
|
|
73
|
+
far_future = datetime.now(timezone.utc).replace(
|
|
74
|
+
year=datetime.now().year + MAX_YEARS_AHEAD + 1
|
|
75
|
+
)
|
|
76
|
+
rrule = f"FREQ=DAILY;UNTIL={far_future.strftime('%Y%m%dT%H%M%SZ')}"
|
|
77
|
+
is_valid, error = RRuleValidator.validate_rrule(rrule)
|
|
78
|
+
assert is_valid is False
|
|
79
|
+
assert f"more than {MAX_YEARS_AHEAD} years" in error
|
|
80
|
+
|
|
81
|
+
def test_invalid_until_format(self):
|
|
82
|
+
"""Test invalid UNTIL date format."""
|
|
83
|
+
is_valid, error = RRuleValidator.validate_rrule("FREQ=DAILY;UNTIL=invalid")
|
|
84
|
+
assert is_valid is False
|
|
85
|
+
assert "UNTIL" in error # Changed to match actual error message
|
|
86
|
+
|
|
87
|
+
def test_valid_with_interval(self):
|
|
88
|
+
"""Test valid RRULE with interval."""
|
|
89
|
+
is_valid, error = RRuleValidator.validate_rrule(
|
|
90
|
+
"FREQ=WEEKLY;INTERVAL=2;COUNT=10"
|
|
91
|
+
)
|
|
92
|
+
assert is_valid is True
|
|
93
|
+
assert error is None
|
|
94
|
+
|
|
95
|
+
def test_invalid_interval_zero(self):
|
|
96
|
+
"""Test invalid interval of zero."""
|
|
97
|
+
is_valid, error = RRuleValidator.validate_rrule(
|
|
98
|
+
"FREQ=DAILY;INTERVAL=0;COUNT=10"
|
|
99
|
+
)
|
|
100
|
+
assert is_valid is False
|
|
101
|
+
assert "INTERVAL must be at least 1" in error
|
|
102
|
+
|
|
103
|
+
def test_empty_rrule(self):
|
|
104
|
+
"""Test empty RRULE string."""
|
|
105
|
+
is_valid, error = RRuleValidator.validate_rrule("")
|
|
106
|
+
assert is_valid is False
|
|
107
|
+
assert "cannot be empty" in error
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestRRuleExpansion:
|
|
111
|
+
"""Test RRULE expansion to occurrences."""
|
|
112
|
+
|
|
113
|
+
def test_expand_daily_occurrences(self):
|
|
114
|
+
"""Test expanding daily occurrences."""
|
|
115
|
+
start = datetime.now(timezone.utc).replace(
|
|
116
|
+
hour=10, minute=0, second=0, microsecond=0
|
|
117
|
+
)
|
|
118
|
+
occurrences = RRuleValidator.expand_occurrences("FREQ=DAILY;COUNT=5", start)
|
|
119
|
+
|
|
120
|
+
assert len(occurrences) == 5
|
|
121
|
+
# Check dates are consecutive days
|
|
122
|
+
for i in range(1, 5):
|
|
123
|
+
assert (
|
|
124
|
+
occurrences[i].date() == (occurrences[i - 1] + timedelta(days=1)).date()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_expand_weekly_occurrences(self):
|
|
128
|
+
"""Test expanding weekly occurrences."""
|
|
129
|
+
start = datetime.now(timezone.utc).replace(
|
|
130
|
+
hour=14, minute=0, second=0, microsecond=0
|
|
131
|
+
)
|
|
132
|
+
occurrences = RRuleValidator.expand_occurrences(
|
|
133
|
+
"FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6", start
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert len(occurrences) == 6
|
|
137
|
+
|
|
138
|
+
def test_expand_with_end_date(self):
|
|
139
|
+
"""Test expanding with end date limit."""
|
|
140
|
+
start = datetime.now(timezone.utc)
|
|
141
|
+
end = start + timedelta(days=10)
|
|
142
|
+
|
|
143
|
+
occurrences = RRuleValidator.expand_occurrences(
|
|
144
|
+
"FREQ=DAILY;COUNT=30", # Would generate 30, but limited by end_date
|
|
145
|
+
start,
|
|
146
|
+
end_date=end,
|
|
147
|
+
)
|
|
148
|
+
# Should only return occurrences within the 10-day window
|
|
149
|
+
assert len(occurrences) <= 11 # 10 days + start day
|
|
150
|
+
assert all(occ <= end for occ in occurrences)
|
|
151
|
+
|
|
152
|
+
def test_expand_with_limit(self):
|
|
153
|
+
"""Test expanding with occurrence limit."""
|
|
154
|
+
start = datetime.now(timezone.utc)
|
|
155
|
+
|
|
156
|
+
occurrences = RRuleValidator.expand_occurrences(
|
|
157
|
+
"FREQ=DAILY;COUNT=1000", # Large count
|
|
158
|
+
start,
|
|
159
|
+
limit=50,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
assert len(occurrences) == 50
|
|
163
|
+
|
|
164
|
+
def test_expand_invalid_rrule(self):
|
|
165
|
+
"""Test expanding invalid RRULE returns empty list."""
|
|
166
|
+
start = datetime.now(timezone.utc)
|
|
167
|
+
occurrences = RRuleValidator.expand_occurrences("INVALID", start)
|
|
168
|
+
|
|
169
|
+
assert occurrences == []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestRRuleInfo:
|
|
173
|
+
"""Test RRULE information extraction."""
|
|
174
|
+
|
|
175
|
+
def test_get_rrule_info_complete(self):
|
|
176
|
+
"""Test extracting complete RRULE information."""
|
|
177
|
+
info = RRuleValidator.get_rrule_info(
|
|
178
|
+
"FREQ=WEEKLY;INTERVAL=2;COUNT=10;BYDAY=MO,WE,FR"
|
|
179
|
+
)
|
|
180
|
+
assert info["frequency"] == "WEEKLY"
|
|
181
|
+
assert info["interval"] == 2
|
|
182
|
+
assert info["count"] == 10
|
|
183
|
+
assert info["byday"] == ["MO", "WE", "FR"]
|
|
184
|
+
assert info["until"] is None
|
|
185
|
+
assert info["bymonthday"] is None
|
|
186
|
+
|
|
187
|
+
def test_get_rrule_info_minimal(self):
|
|
188
|
+
"""Test extracting minimal RRULE information."""
|
|
189
|
+
info = RRuleValidator.get_rrule_info("FREQ=DAILY;COUNT=5")
|
|
190
|
+
|
|
191
|
+
assert info["frequency"] == "DAILY"
|
|
192
|
+
assert info["interval"] == 1 # Default
|
|
193
|
+
assert info["count"] == 5
|
|
194
|
+
assert info["byday"] is None
|
|
195
|
+
assert info["until"] is None
|
|
196
|
+
|
|
197
|
+
def test_get_rrule_info_with_until(self):
|
|
198
|
+
"""Test extracting RRULE with UNTIL."""
|
|
199
|
+
info = RRuleValidator.get_rrule_info(
|
|
200
|
+
"FREQ=MONTHLY;UNTIL=20251231T235959Z;BYMONTHDAY=15"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
assert info["frequency"] == "MONTHLY"
|
|
204
|
+
assert info["until"] == "20251231T235959Z"
|
|
205
|
+
assert info["bymonthday"] == [15]
|
|
206
|
+
assert info["count"] is None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestRRuleTemplates:
|
|
210
|
+
"""Test RRULE template constants."""
|
|
211
|
+
|
|
212
|
+
def test_template_formats(self):
|
|
213
|
+
"""Test that templates have correct format."""
|
|
214
|
+
assert RRuleTemplates.DAILY_WEEKDAYS == "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"
|
|
215
|
+
assert RRuleTemplates.MONTHLY_LAST_DAY == "FREQ=MONTHLY;BYMONTHDAY=-1"
|
|
216
|
+
assert "{day}" in RRuleTemplates.WEEKLY_ON_DAY
|
|
217
|
+
assert "{days}" in RRuleTemplates.WEEKLY_MULTIPLE_DAYS
|