aboutsummaryrefslogtreecommitdiff
path: root/tests/test_calendar_sync_api.py
blob: 36b0a14ab6083cb66f61acb6584e6bc7775db87b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""Tests for the Google Calendar API sync helper.

Pure rendering functions only — auth and fetch are thin wrappers
around the Google libraries and get smoke-tested manually after
OAuth setup.

Run: python3 -m unittest tests/test_calendar_sync_api.py
"""

from __future__ import annotations

import json
import sys
import unittest
from pathlib import Path

# Add scripts/ to path so we can import the helper as a module.
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))

import calendar_sync_api as api  # noqa: E402

FIXTURES = Path(__file__).parent / "fixtures" / "calendar-sync-api"


def load_fixture(name):
    return json.loads((FIXTURES / name).read_text())


# --- self_response_status ---

class TestSelfResponseStatus(unittest.TestCase):
    """Normal/Boundary/Error coverage for self_response_status."""

    def test_normal_finds_self_declined(self):
        ev = load_fixture("declined-recurring.json")
        self.assertEqual(api.self_response_status(ev), "declined")

    def test_normal_finds_self_accepted(self):
        ev = load_fixture("accepted-with-conference.json")
        self.assertEqual(api.self_response_status(ev), "accepted")

    def test_boundary_no_attendees_returns_none(self):
        ev = load_fixture("all-day-multi.json")
        self.assertIsNone(api.self_response_status(ev))

    def test_boundary_attendees_without_self_flag_returns_none(self):
        ev = {"attendees": [
            {"email": "other@example.com", "responseStatus": "accepted"},
        ]}
        self.assertIsNone(api.self_response_status(ev))

    def test_error_missing_attendees_key(self):
        self.assertIsNone(api.self_response_status({}))


# --- filter_declined ---

class TestFilterDeclined(unittest.TestCase):

    def setUp(self):
        self.events = [
            load_fixture("declined-recurring.json"),
            load_fixture("accepted-with-conference.json"),
            load_fixture("all-day-multi.json"),
        ]

    def test_normal_drops_self_declined(self):
        result = api.filter_declined(self.events, skip_declined=True)
        self.assertEqual(len(result), 2)
        for e in result:
            self.assertNotEqual(api.self_response_status(e), "declined")

    def test_normal_toggle_off_keeps_all(self):
        result = api.filter_declined(self.events, skip_declined=False)
        self.assertEqual(len(result), 3)

    def test_boundary_empty_list(self):
        self.assertEqual(api.filter_declined([], skip_declined=True), [])

    def test_boundary_none_declined(self):
        events = [load_fixture("accepted-with-conference.json")]
        self.assertEqual(len(api.filter_declined(events)), 1)


# --- format_org_timestamp ---

class TestFormatOrgTimestamp(unittest.TestCase):

    def test_normal_timed_same_day(self):
        start = {"dateTime": "2026-05-19T14:45:00-04:00"}
        end   = {"dateTime": "2026-05-19T15:45:00-04:00"}
        # Expected uses the offset's local time directly (no TZ conversion
        # needed when the dateTime already carries its offset).
        self.assertEqual(
            api.format_org_timestamp(start, end),
            "<2026-05-19 Tue 14:45-15:45>",
        )

    def test_normal_all_day_single(self):
        start = {"date": "2026-05-19"}
        end   = {"date": "2026-05-20"}
        self.assertEqual(
            api.format_org_timestamp(start, end),
            "<2026-05-19 Tue>",
        )

    def test_boundary_all_day_multi(self):
        start = {"date": "2026-05-17"}
        end   = {"date": "2026-05-22"}
        # End is exclusive in the Google API; last inclusive day is 5/21.
        self.assertEqual(
            api.format_org_timestamp(start, end),
            "<2026-05-17 Sun>--<2026-05-21 Thu>",
        )

    def test_boundary_timed_no_end(self):
        start = {"dateTime": "2026-05-19T14:45:00-04:00"}
        self.assertEqual(
            api.format_org_timestamp(start, None),
            "<2026-05-19 Tue 14:45>",
        )


# --- HTML / text cleaning ---

class TestStripHtml(unittest.TestCase):

    def test_normal_strips_tags(self):
        self.assertEqual(api.strip_html("Hello <b>world</b>"), "Hello world")

    def test_normal_converts_br_to_newline(self):
        self.assertEqual(api.strip_html("Line 1<br>Line 2"), "Line 1\nLine 2")
        self.assertEqual(api.strip_html("Line 1<br/>Line 2"), "Line 1\nLine 2")
        self.assertEqual(api.strip_html("Line 1<BR />Line 2"), "Line 1\nLine 2")

    def test_normal_decodes_entities(self):
        self.assertEqual(api.strip_html("AT&amp;T &quot;hi&quot;"), 'AT&T "hi"')

    def test_boundary_empty_string(self):
        self.assertEqual(api.strip_html(""), "")

    def test_error_none_input(self):
        self.assertIsNone(api.strip_html(None))


class TestSanitizeHeading(unittest.TestCase):

    def test_normal_collapses_whitespace(self):
        self.assertEqual(api.sanitize_heading("a   b\n\tc"), "a b c")

    def test_normal_neutralizes_leading_stars(self):
        # Leading "* " in heading text would create a nested heading.
        self.assertEqual(api.sanitize_heading("* foo"), "- foo")

    def test_boundary_empty(self):
        self.assertEqual(api.sanitize_heading(""), "")


# --- extract_meeting_url ---

class TestExtractMeetingUrl(unittest.TestCase):

    def test_normal_prefers_conference_data_video(self):
        ev = load_fixture("accepted-with-conference.json")
        self.assertEqual(
            api.extract_meeting_url(ev),
            "https://meet.google.com/abc-defg-hij",
        )

    def test_normal_falls_back_to_hangout_link(self):
        ev = load_fixture("declined-recurring.json")
        self.assertEqual(
            api.extract_meeting_url(ev),
            "https://meet.google.com/suw-ornf-iuv",
        )

    def test_boundary_no_meeting_url(self):
        ev = load_fixture("all-day-multi.json")
        self.assertIsNone(api.extract_meeting_url(ev))


# --- render_event ---

class TestRenderEvent(unittest.TestCase):

    def test_normal_renders_accepted_meeting(self):
        ev = load_fixture("accepted-with-conference.json")
        out = api.render_event(ev)
        # Heading + timestamp + property drawer + description body
        self.assertIn("* Breakout: Machine Speed, Human Will", out)
        self.assertIn("<2026-05-19 Tue 14:45-15:45>", out)
        self.assertIn(":LOCATION: Marriott Water Street: Florida Salon V - VI / Breakout Room 2", out)
        self.assertIn(":ORGANIZER: Eric Bell", out)
        self.assertIn(":STATUS: accepted", out)
        self.assertIn(":URL: https://meet.google.com/abc-defg-hij", out)
        self.assertIn("Bring your laptop", out)
        # HTML <br/> got normalized to newlines, not left as raw tags.
        self.assertNotIn("<br", out)

    def test_normal_renders_all_day_event(self):
        ev = load_fixture("all-day-multi.json")
        out = api.render_event(ev)
        self.assertIn("* SOFWeek — Tampa", out)
        self.assertIn("<2026-05-17 Sun>--<2026-05-21 Thu>", out)
        self.assertIn(":LOCATION: Tampa, FL", out)
        # No :STATUS: when there's no attendee block.
        self.assertNotIn(":STATUS:", out)

    def test_normal_renders_minimal_self_event(self):
        ev = load_fixture("all-day-single.json")
        out = api.render_event(ev)
        self.assertIn("* Home", out)
        self.assertIn("<2026-05-19 Tue>", out)
        # No property drawer at all when none of the optional fields are set.
        self.assertNotIn(":PROPERTIES:", out)

    def test_boundary_missing_summary_falls_back(self):
        ev = {"start": {"date": "2026-05-19"}, "end": {"date": "2026-05-20"}}
        out = api.render_event(ev)
        self.assertIn("* (No Title)", out)

    def test_error_missing_start_returns_none(self):
        ev = {"summary": "Broken"}
        self.assertIsNone(api.render_event(ev))


# --- render_calendar end-to-end with filter ---

class TestRenderCalendar(unittest.TestCase):

    def test_normal_filters_declined_and_renders_rest(self):
        events = [
            load_fixture("declined-recurring.json"),
            load_fixture("accepted-with-conference.json"),
            load_fixture("all-day-multi.json"),
        ]
        events = api.filter_declined(events, skip_declined=True)
        out = api.render_calendar(events)
        # Declined event must not appear.
        self.assertNotIn("STRATFI/FR Standup", out)
        # Accepted ones must appear.
        self.assertIn("Breakout: Machine Speed", out)
        self.assertIn("SOFWeek — Tampa", out)
        # Header is the same shape the Elisp parser produces.
        self.assertTrue(out.startswith("# Calendar Events\n"))


if __name__ == "__main__":
    unittest.main()