"""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 world"), "Hello world")
def test_normal_converts_br_to_newline(self):
self.assertEqual(api.strip_html("Line 1
Line 2"), "Line 1\nLine 2")
self.assertEqual(api.strip_html("Line 1
Line 2"), "Line 1\nLine 2")
self.assertEqual(api.strip_html("Line 1
Line 2"), "Line 1\nLine 2")
def test_normal_decodes_entities(self):
self.assertEqual(api.strip_html("AT&T "hi""), '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
got normalized to newlines, not left as raw tags.
self.assertNotIn("
--<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()