From 3aa3f5aa4b3a645db4804493991aeba2b3c22ed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 13:17:10 +0000 Subject: [PATCH 1/3] fix(e2e): sweep stale 2026-06-07 seed dates + hardcoded solver windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Follow-up from PR #227. That PR bumped `2026-06-07` in four e2e files that watch `/v/open` (which filters out past events), but explicitly left the remaining seven for a separate sweep because their tests also seed a hardcoded solver window (`from=2026-05-19, to=2026-06-30`) that had to move in lockstep with the seed event date. Today (2026-07-13) is already past `2026-06-07`, so a solve-and-publish flow can produce an empty solution and drop /v/schedule assertions. Adds `solver_window_around(seed_iso)` to `tests/e2e/_helpers.py` returning a (from, to) 3-week bracket, then replaces the literal seed date with `next_sunday_iso()` and the literal window with `solver_window_around()` across all seven files. Changed files: - tests/e2e/_helpers.py — new `solver_window_around()` helper (docstring documents the rot pattern it prevents). - tests/e2e/test_smoke_full_loop.py, tests/e2e/test_availability_solver.py, tests/e2e/test_bulk_notify.py, tests/e2e/test_calendar_subscription.py, tests/e2e/test_onboarding_wizard.py — inline seed/window inside the single test function. - tests/e2e/test_notif_assign_unpublish.py, tests/e2e/test_analytics_recurring_swapreview.py — module-level `EV_DATE` / `FROM_DATE` / `TO_DATE` so the shared `_new_event` / `_solve_and_publish` helpers stay parameterless. - tests/unit/test_e2e_helpers.py — new: 3 invariants for the helpers (Sunday-in-future, seed strictly inside window, deterministic). Validation: - `poetry run pytest tests/unit/test_e2e_helpers.py tests/unit/test_e2e_lane.py` → 5 passed - `make test-unit-fast` → 341 passed, 21 skipped - `poetry run pytest tests/api tests/contract tests/web` → 550 passed - `poetry run black --check api tests` → clean - `poetry run ruff check api tests` → clean - `python -m py_compile` on all touched files → clean Follow-ups: - `2026-07-05` in test_analytics_recurring_swapreview.py (recurring-series start date, `#rs_sd`) is not on the same solve-and-publish path and is still ~2 weeks in the future — leaving alone to keep this sweep narrow. Worth bumping alongside a future rot fix. --- tests/e2e/_helpers.py | 17 +++++++++ .../test_analytics_recurring_swapreview.py | 19 ++++++++-- tests/e2e/test_availability_solver.py | 22 ++++++++--- tests/e2e/test_bulk_notify.py | 18 +++++++-- tests/e2e/test_calendar_subscription.py | 18 +++++++-- tests/e2e/test_notif_assign_unpublish.py | 19 ++++++++-- tests/e2e/test_onboarding_wizard.py | 18 +++++++-- tests/e2e/test_smoke_full_loop.py | 18 +++++++-- tests/unit/test_e2e_helpers.py | 38 +++++++++++++++++++ 9 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_e2e_helpers.py diff --git a/tests/e2e/_helpers.py b/tests/e2e/_helpers.py index de376ca3..dda9193a 100644 --- a/tests/e2e/_helpers.py +++ b/tests/e2e/_helpers.py @@ -30,6 +30,23 @@ def next_sunday_iso() -> str: return (today + timedelta(days=days_until_sunday)).isoformat() +def solver_window_around(seed_iso: str) -> tuple[str, str]: + """Return (from_date, to_date) ISO strings that safely bracket a seed + event date for `/a/solver`. + + Flows that seed an event with `next_sunday_iso()` then run the solver + over a window must pick from/to dates that comfortably contain the + seed. Fixed literal windows (e.g. from=2026-05-19, to=2026-06-30) + rot as time passes and drop the seed event outside the window. This + helper returns a 3-week-before / 3-week-after bracket around the + seed, mirroring the pattern the sweep replaces. + """ + seed = date.fromisoformat(seed_iso) + from_date = (seed - timedelta(days=21)).isoformat() + to_date = (seed + timedelta(days=21)).isoformat() + return from_date, to_date + + def signup_admin( page, base_url, *, org="Hope Chapel", name="Admin Dana", email=None, password="HopePass123!" ): diff --git a/tests/e2e/test_analytics_recurring_swapreview.py b/tests/e2e/test_analytics_recurring_swapreview.py index 10561164..01978043 100644 --- a/tests/e2e/test_analytics_recurring_swapreview.py +++ b/tests/e2e/test_analytics_recurring_swapreview.py @@ -7,17 +7,28 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e +EV_DATE = next_sunday_iso() +FROM_DATE, TO_DATE = solver_window_around(EV_DATE) + def _new_event(page, base, etype): page.goto(f"{base}/a/events") page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", etype) - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", EV_DATE) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", "volunteer") @@ -28,8 +39,8 @@ def _new_event(page, base, etype): def _solve_and_publish(page, base): page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", FROM_DATE) + page.fill("#to_date", TO_DATE) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") page.click("a:has-text('Review solution')") diff --git a/tests/e2e/test_availability_solver.py b/tests/e2e/test_availability_solver.py index fa3aef11..24a0037d 100644 --- a/tests/e2e/test_availability_solver.py +++ b/tests/e2e/test_availability_solver.py @@ -10,7 +10,15 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e @@ -31,10 +39,12 @@ def test_timeoff_blocks_solver_assignment(live_server, new_context, page, db_pat vol_page = accept_invitation(new_context(), base, invite_token(db_path, vol_email)) # Volunteer books time-off covering the event date. + ev_date = next_sunday_iso() + from_date, to_date = solver_window_around(ev_date) vol_page.goto(f"{base}/v/availability") vol_page.wait_for_selector("#start_date") - vol_page.fill("#start_date", "2026-06-07") - vol_page.fill("#end_date", "2026-06-07") + vol_page.fill("#start_date", ev_date) + vol_page.fill("#end_date", ev_date) vol_page.fill("#reason", "Vacation") vol_page.click("button:has-text('Add time-off')") vol_page.wait_for_selector("#timeoff-list:has-text('Vacation')") @@ -44,7 +54,7 @@ def test_timeoff_blocks_solver_assignment(live_server, new_context, page, db_pat page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", "Sunday 10am Service") - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", ev_date) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", "volunteer") @@ -54,8 +64,8 @@ def test_timeoff_blocks_solver_assignment(live_server, new_context, page, db_pat # Solve, then publish. page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", from_date) + page.fill("#to_date", to_date) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") page.click("a:has-text('Review solution')") diff --git a/tests/e2e/test_bulk_notify.py b/tests/e2e/test_bulk_notify.py index 2ad90bb2..2210b6ac 100644 --- a/tests/e2e/test_bulk_notify.py +++ b/tests/e2e/test_bulk_notify.py @@ -8,7 +8,15 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e @@ -28,11 +36,13 @@ def test_notify_published_schedule_reaches_inbox(live_server, new_context, page, page.wait_for_selector("#invite-result:has-text('Invitation sent')") vol_page = accept_invitation(new_context(), base, invite_token(db_path, vol_email)) + ev_date = next_sunday_iso() + from_date, to_date = solver_window_around(ev_date) page.goto(f"{base}/a/events") page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", "Sunday 10am Service") - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", ev_date) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", "volunteer") @@ -41,8 +51,8 @@ def test_notify_published_schedule_reaches_inbox(live_server, new_context, page, page.wait_for_selector("#events-list:has-text('Sunday 10am Service')") page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", from_date) + page.fill("#to_date", to_date) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") page.click("a:has-text('Review solution')") diff --git a/tests/e2e/test_calendar_subscription.py b/tests/e2e/test_calendar_subscription.py index bb9d8eb2..674fd275 100644 --- a/tests/e2e/test_calendar_subscription.py +++ b/tests/e2e/test_calendar_subscription.py @@ -10,7 +10,15 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e @@ -38,11 +46,13 @@ def test_subscription_gates_on_publish(live_server, new_context, page, db_path): page.wait_for_selector("#invite-result:has-text('Invitation sent')") vol_page = accept_invitation(new_context(), base, invite_token(db_path, vol_email)) + ev_date = next_sunday_iso() + from_date, to_date = solver_window_around(ev_date) page.goto(f"{base}/a/events") page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", "Sunday 10am Service") - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", ev_date) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", "volunteer") @@ -52,8 +62,8 @@ def test_subscription_gates_on_publish(live_server, new_context, page, db_path): # Solve — produces a DRAFT (unpublished) solution + assignment. page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", from_date) + page.fill("#to_date", to_date) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") diff --git a/tests/e2e/test_notif_assign_unpublish.py b/tests/e2e/test_notif_assign_unpublish.py index 75ad86b1..658ea9d9 100644 --- a/tests/e2e/test_notif_assign_unpublish.py +++ b/tests/e2e/test_notif_assign_unpublish.py @@ -9,17 +9,28 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e +EV_DATE = next_sunday_iso() +FROM_DATE, TO_DATE = solver_window_around(EV_DATE) + def _new_event(page, base, etype="Sunday 10am Service", role="volunteer", count="1"): page.goto(f"{base}/a/events") page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", etype) - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", EV_DATE) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", role) @@ -30,8 +41,8 @@ def _new_event(page, base, etype="Sunday 10am Service", role="volunteer", count= def _solve_and_publish(page, base): page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", FROM_DATE) + page.fill("#to_date", TO_DATE) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") page.click("a:has-text('Review solution')") diff --git a/tests/e2e/test_onboarding_wizard.py b/tests/e2e/test_onboarding_wizard.py index b184f96b..eeb901ea 100644 --- a/tests/e2e/test_onboarding_wizard.py +++ b/tests/e2e/test_onboarding_wizard.py @@ -9,7 +9,15 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e @@ -40,11 +48,13 @@ def test_fresh_admin_completes_wizard(live_server, new_context, page, db_path): _progress(page, base, "1 of 4 done") # 2) Create an event. + ev_date = next_sunday_iso() + from_date, to_date = solver_window_around(ev_date) page.goto(f"{base}/a/events") page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", "Sunday 10am Service") - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", ev_date) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", "volunteer") @@ -57,8 +67,8 @@ def test_fresh_admin_completes_wizard(live_server, new_context, page, db_path): # sequence (the solver page only holds the result until you navigate # away, so the review link must be clicked without leaving). page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", from_date) + page.fill("#to_date", to_date) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") page.click("a:has-text('Review solution')") diff --git a/tests/e2e/test_smoke_full_loop.py b/tests/e2e/test_smoke_full_loop.py index 6e5ab15b..9bc00ed5 100644 --- a/tests/e2e/test_smoke_full_loop.py +++ b/tests/e2e/test_smoke_full_loop.py @@ -9,7 +9,15 @@ import pytest -from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin +from tests.e2e._helpers import ( + accept_invitation, + invite_token, + next_sunday_iso, + no_js_errors, + rid, + signup_admin, + solver_window_around, +) pytestmark = pytest.mark.e2e @@ -37,11 +45,13 @@ def test_full_loop(live_server, new_context, page, db_path): vol_page = accept_invitation(new_context(), base, tok) # Admin creates an event with a role the solver can fill. + ev_date = next_sunday_iso() + from_date, to_date = solver_window_around(ev_date) page.goto(f"{base}/a/events") page.click("button:has-text('New event')") page.wait_for_selector("#ev_type", state="visible") page.fill("#ev_type", "Sunday 10am Service") - page.fill("#ev_date", "2026-06-07") + page.fill("#ev_date", ev_date) page.fill("#ev_start", "10:00") page.fill("#ev_end", "11:30") page.fill("input[name=role_name]", "volunteer") @@ -51,8 +61,8 @@ def test_full_loop(live_server, new_context, page, db_path): # Solve. page.goto(f"{base}/a/solver") - page.fill("#from_date", "2026-05-19") - page.fill("#to_date", "2026-06-30") + page.fill("#from_date", from_date) + page.fill("#to_date", to_date) page.click("button:has-text('Run solver')") page.wait_for_selector("#solver-result:has-text('Review solution')") page.click("a:has-text('Review solution')") diff --git a/tests/unit/test_e2e_helpers.py b/tests/unit/test_e2e_helpers.py new file mode 100644 index 00000000..522b9577 --- /dev/null +++ b/tests/unit/test_e2e_helpers.py @@ -0,0 +1,38 @@ +"""Invariants for `tests/e2e/_helpers` date utilities. + +These lock in the properties the e2e-lane relies on: +- `next_sunday_iso()` returns a Sunday at least 7 days in the future. +- `solver_window_around(seed)` returns (from, to) that comfortably + brackets `seed` so the seed always falls inside the solver window. +""" + +from __future__ import annotations + +from datetime import date + +from tests.e2e._helpers import next_sunday_iso, solver_window_around + + +def test_next_sunday_iso_is_a_future_sunday(): + seed = date.fromisoformat(next_sunday_iso()) + assert seed.weekday() == 6, "helper must return a Sunday" + assert (seed - date.today()).days >= 7, "helper must return a date at least 7 days from today" + + +def test_solver_window_brackets_seed(): + seed_iso = next_sunday_iso() + seed = date.fromisoformat(seed_iso) + from_iso, to_iso = solver_window_around(seed_iso) + from_d = date.fromisoformat(from_iso) + to_d = date.fromisoformat(to_iso) + assert from_d < seed < to_d, "seed must fall strictly inside (from, to)" + assert (seed - from_d).days >= 7, "from_date must be well before seed" + assert (to_d - seed).days >= 7, "to_date must be well after seed" + + +def test_solver_window_deterministic_from_seed(): + from_a, to_a = solver_window_around("2026-08-02") + from_b, to_b = solver_window_around("2026-08-02") + assert (from_a, to_a) == (from_b, to_b) + assert from_a == "2026-07-12" + assert to_a == "2026-08-23" From 129aa168e6843884f50aa188b67a8e00a90a4392 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 13:23:12 +0000 Subject: [PATCH 2/3] chore: re-trigger CI after Service Unavailable infra flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior run's "Lint, type-check, and test" job failed at step 1 (Set up job) with "Failed to resolve action download info. Error: Service Unavailable" — a GitHub Actions runner-provisioning outage, not a real test failure. The e2e job on the same commit passed. From 8a501a03837810f04cb91f7d331f3295415f167e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 13:29:49 +0000 Subject: [PATCH 3/3] chore: second retrigger after GHA action-metadata outage