"""Live end-to-end test against the real Google Sheets API. This is intentionally excluded from the default test run (it is marked ``e2e`` and ``addopts`` in pyproject.toml deselects that marker). It writes a small mock masterplan (fictional Karlsruhe participants) into the spreadsheet configured via ``GOOGLE_SHEETS_SPREADSHEET_ID``/``GOOGLE_SHEETS_CREDENTIALS_FILE``, then reads the cells back via the live API to validate the export. Note this overwrites/deletes tabs in that spreadsheet (the same idempotent rewrite ``export_masterplan_to_sheet`` always does) — point it at a scratch/test sheet. Run it explicitly: uv run pytest -m e2e -k sheets It requires a real service-account key file and a spreadsheet ID, with that spreadsheet shared (Editor) with the service account's ``client_email``; the test skips itself if either is only a placeholder/missing. """ import os import pytest from dotenv import load_dotenv from tatami.classes import Group, Participant from tatami.sheets_export import ( OVERVIEW_TITLE, _group_titles, export_masterplan_to_sheet, load_sheets_client, ) from tatami.tatami_masterplan import assign_courses, get_after_party_group pytestmark = pytest.mark.e2e _PLACEHOLDER_SPREADSHEET_IDS = {"", "your-spreadsheet-id-here"} _PLACEHOLDER_CREDENTIALS_FILES = {"", "service-account.json"} COURSE_TIMES = { "starter": "18:30", "main": "20:00", "dessert": "22:00", "after_party": "23:30", } ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-1234567")] INFO_TEXT = "Be on time.\nBring a small gift for your hosts." @pytest.fixture def live_spreadsheet(): # Prefer real values from the environment / .env over conftest's dummies. load_dotenv(override=True) credentials_file = os.environ.get("GOOGLE_SHEETS_CREDENTIALS_FILE", "") spreadsheet_id = os.environ.get("GOOGLE_SHEETS_SPREADSHEET_ID", "") if ( credentials_file in _PLACEHOLDER_CREDENTIALS_FILES or not os.path.isfile(os.path.expanduser(credentials_file)) or spreadsheet_id in _PLACEHOLDER_SPREADSHEET_IDS ): pytest.skip( "No real GOOGLE_SHEETS_CREDENTIALS_FILE/GOOGLE_SHEETS_SPREADSHEET_ID " "available; skipping live Sheets test." ) client = load_sheets_client(credentials_file) return client.open_by_key(spreadsheet_id) def _make_mock_groups() -> tuple[list[Group], Group]: """3 groups of 3 fictional participants with addresses around Karlsruhe.""" hosts = [ Participant( name="Anna Wagner", address="Kaiserstraße 12, 76131 Karlsruhe", phone="0721-1000001", kitchen_size=9, allergies="none", ), Participant( name="Jonas Becker", address="Waldstraße 5, 76133 Karlsruhe", phone="0721-1000002", kitchen_size=7, allergies="lactose", ), Participant( name="Mira Hofmann", address="Yorckstraße 22, 76185 Karlsruhe", phone="0721-1000003", kitchen_size=8, allergies="none", ), ] semi_hosts = [ Participant( name="Lukas Schreiber", address="Sophienstraße 40, 76135 Karlsruhe", phone="0721-1000004", kitchen_size=5, allergies="nuts", ), Participant( name="Sophie Lindner", address="Beiertheimer Allee 18, 76137 Karlsruhe", phone="0721-1000005", kitchen_size=6, allergies="none", ), Participant( name="Tom Vogel", address="Durlacher Allee 75, 76131 Karlsruhe", phone="0721-1000006", kitchen_size=4, allergies="none", ), Participant( name="Lea Brandt", address="Moltkestraße 30, 76133 Karlsruhe", phone="0721-1000007", kitchen_size=3, allergies="gluten", ), Participant( name="Felix Krause", address="Adlerstraße 14, 76133 Karlsruhe", phone="0721-1000008", kitchen_size=8, allergies="none", ), Participant( name="Nora Fink", address="Rüppurrer Straße 60, 76137 Karlsruhe", phone="0721-1000009", kitchen_size=5, allergies="none", ), ] groups = [Group(members=[host]) for host in hosts] for i, member in enumerate(semi_hosts): groups[i % len(groups)].add_member(member) assign_courses(groups, ["starter", "main", "dessert"]) after_party = get_after_party_group("Sebastian-Kneipp-Straße 6, 76131 Karlsruhe") return groups, after_party def test_export_masterplan_to_live_sheet(live_spreadsheet): groups, after_party = _make_mock_groups() export_masterplan_to_sheet( live_spreadsheet, groups, after_party, COURSE_TIMES, organizer_contacts=ORGANIZER_CONTACTS, info_text=INFO_TEXT, ) all_members = [member for group in groups for member in group.members] group_titles = _group_titles(groups) expected_titles = {OVERVIEW_TITLE, *group_titles} # Only the export's own tabs survive — stale/default tabs are cleaned up. assert {w.title for w in live_spreadsheet.worksheets()} == expected_titles # A group's tab is named after all its members, not just the host. starter_title = next( title for group, title in zip(groups, group_titles) if group.course == "starter" ) starter_group = next(g for g in groups if g.course == "starter") assert starter_title != starter_group.main_member.name overview_rows = live_spreadsheet.worksheet(OVERVIEW_TITLE).get_all_values() assert overview_rows[0] == [ "Group", "Course", "Name", "Phone", "Address", "Allergies", ] member_rows = overview_rows[1 : 1 + len(all_members)] assert {row[2] for row in member_rows} == {m.name for m in all_members} def _find_overview_row(prefix: list[str]) -> int: return next( i for i, row in enumerate(overview_rows) if row[: len(prefix)] == prefix ) times_idx = _find_overview_row(["Meal Times"]) assert overview_rows[times_idx + 1][:2] == ["Course", "Time"] times_rows = overview_rows[times_idx + 2 : times_idx + 2 + len(COURSE_TIMES)] assert [row[:2] for row in times_rows] == [ [course, time] for course, time in COURSE_TIMES.items() ] contacts_idx = _find_overview_row(["Support Contacts"]) assert overview_rows[contacts_idx + 1][:2] == ["Name", "Contact"] assert overview_rows[contacts_idx + 2][:2] == list(ORGANIZER_CONTACTS[0]) info_idx = _find_overview_row(["Info"]) info_lines = [row[0] for row in overview_rows[info_idx + 1 :]] assert info_lines == INFO_TEXT.splitlines() tab_rows = live_spreadsheet.worksheet(starter_title).get_all_values() # The live API pads every row to the widest row in the tab, so match on a # row's leading cells rather than exact-length equality. def _find_row(prefix: list[str]) -> int: return next(i for i, row in enumerate(tab_rows) if row[: len(prefix)] == prefix) route_idx = _find_row(["Route", "Host", "Address", "Time"]) route_rows = tab_rows[route_idx + 1 : route_idx + 5] assert [row[0] for row in route_rows] == [ "starter", "main", "dessert", "After Party", ] assert [row[3] for row in route_rows] == ["18:30", "20:00", "22:00", "23:30"] guest_idx = _find_row(["Guests for your course", "Allergies"]) guest_rows = [row for row in tab_rows[guest_idx + 1 :] if row and row[0]] expected_guests = {(p.name, p.allergies) for p in starter_group.get_guests(groups)} assert {(row[0], row[1]) for row in guest_rows} == expected_guests