Files
Tatami/tests/test_e2e_sheets.py
lars 6b0fca0100 Move Participant/Group to pydantic and add a saveable/editable Plan model
Participant and Group are now pydantic BaseModels. Group.hosts can form
cycles between groups, so it's kept as a private, non-persisted live list
(set via set_hosts()/add_host()) backed by a serializable host_uuids field,
re-linked via resolve_hosts() after a reload.

A new Plan model (src/tatami/plan.py) bundles groups, the after-party group,
and the event config (course_times, organizer_contacts, info_text,
spreadsheet_id) and supports save()/load() to/from JSON. tatami_masterplan's
__main__ now saves to masterplan.json (PLAN_FILE env var to override) on
first run and loads it on later runs instead of recomputing, so the plan can
be hand-edited (move a member between groups, change a course, fill in
spreadsheet_id) and picked up on rerun without hitting the Routes API again.
spreadsheet_id moves out of .env (GOOGLE_SHEETS_SPREADSHEET_ID) onto the plan
itself, since it's part of the plan rather than a secret.
2026-06-19 16:07:47 +02:00

229 lines
7.8 KiB
Python

"""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