diff --git a/tests/e2e/test_decline_reopen.py b/tests/e2e/test_decline_reopen.py new file mode 100644 index 00000000..ac33ae65 --- /dev/null +++ b/tests/e2e/test_decline_reopen.py @@ -0,0 +1,78 @@ +"""Overnight B8 e2e — declining a shift re-opens it for another volunteer. + +Alice self-claims an open shift, then declines it. The slot must +re-appear in /v/open so Bob can claim it. +""" + +from __future__ import annotations + +import pytest + +from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin + +pytestmark = pytest.mark.e2e + + +def _invite(page, base, name, email): + page.goto(f"{base}/a/people") + page.click("button:has-text('Invite person')") + page.fill("#inv_name", name) + page.fill("#inv_email", email) + page.select_option("#inv_role", "volunteer") + page.click("button:has-text('Send invite')") + page.wait_for_selector("#invite-result:has-text('Invitation sent')") + + +def test_decline_reopens_slot(live_server, new_context, page, db_path): + base = live_server + a_email = f"alice+{rid()}@hope.e2e" + b_email = f"bob+{rid()}@hope.e2e" + + signup_admin(page, base) + + _invite(page, base, "Alice Vol", a_email) + a_page = accept_invitation(new_context(), base, invite_token(db_path, a_email)) + _invite(page, base, "Bob Vol", b_email) + b_page = accept_invitation(new_context(), base, invite_token(db_path, b_email)) + + 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_start", "10:00") + page.fill("#ev_end", "11:30") + page.fill("input[name=role_name]", "volunteer") + page.fill("input[name=role_count]", "1") + page.click("button:has-text('Create event')") + page.wait_for_selector("#events-list:has-text('Sunday 10am Service')") + + # Alice claims the only open slot. + a_page.goto(f"{base}/v/open") + a_page.wait_for_selector("#open-list:has-text('Sunday 10am Service')") + a_page.click("button:has-text('Claim')") + a_page.wait_for_selector("#open-list:has-text('No open shifts')") + + # With the slot filled, Bob does not see it. + b_page.goto(f"{base}/v/open") + b_page.wait_for_selector("#open-list:has-text('No open shifts')") + + # Alice declines the shift. + a_page.goto(f"{base}/v/schedule") + a_page.click("a:has-text('Sunday 10am Service')") + a_page.wait_for_selector("#assignment-card") + a_page.click("button:text-is('Decline')") + a_page.fill("#decline_reason", "Schedule conflict") + a_page.click("button:text-is('Confirm decline')") + a_page.wait_for_selector("#assignment-card .status-text.declined") + + # The slot re-opens — Bob can now see and claim it. + b_page.goto(f"{base}/v/open") + b_page.wait_for_selector("#open-list:has-text('Sunday 10am Service')") + b_page.click("button:has-text('Claim')") + b_page.wait_for_selector("#open-list:has-text('No open shifts')") + + b_page.goto(f"{base}/v/schedule") + b_page.wait_for_selector("text=Sunday 10am Service") + + no_js_errors(b_page) diff --git a/tests/web/test_decline_reopen.py b/tests/web/test_decline_reopen.py new file mode 100644 index 00000000..081bc26b --- /dev/null +++ b/tests/web/test_decline_reopen.py @@ -0,0 +1,89 @@ +"""Overnight B8 — a declined assignment re-opens its slot.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from api.models import Assignment, Event +from tests.web.conftest import seed_person +from web.deps import SESSION_COOKIE + + +def _vol(client, db, *, org, pid, email): + seed_person(db, person_id=pid, org_id=org, email=email, roles=["volunteer"]) + r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"}) + return r.cookies[SESSION_COOKIE] + + +def _event(db, org, *, eid="dr_ev"): + start = datetime.utcnow() + timedelta(days=7) + db.add( + Event( + id=eid, + org_id=org, + type="Sunday Service", + start_time=start, + end_time=start + timedelta(hours=1), + extra_data={"role_counts": {"volunteer": 1}}, + ) + ) + db.commit() + + +def _assign(db, *, eid, pid, status): + db.add( + Assignment( + event_id=eid, + person_id=pid, + role="volunteer", + status=status, + solution_id=None, + ) + ) + db.commit() + + +def test_confirmed_assignment_hides_open_slot(client, db): + seed_person(db, person_id="dr_a", org_id="dr_o1", email="a@dr.test", roles=["volunteer"]) + tok_b = _vol(client, db, org="dr_o1", pid="dr_b", email="b@dr.test") + _event(db, "dr_o1") + _assign(db, eid="dr_ev", pid="dr_a", status="confirmed") + r = client.get("/v/open", cookies={SESSION_COOKIE: tok_b}) + assert "No open shifts right now" in r.text + assert "Sunday Service" not in r.text + + +def test_declined_assignment_reopens_and_is_claimable(client, db): + seed_person(db, person_id="dr_a2", org_id="dr_o2", email="a2@dr.test", roles=["volunteer"]) + tok_b = _vol(client, db, org="dr_o2", pid="dr_b2", email="b2@dr.test") + _event(db, "dr_o2") + _assign(db, eid="dr_ev", pid="dr_a2", status="declined") + + r = client.get("/v/open", cookies={SESSION_COOKIE: tok_b}) + assert "Sunday Service" in r.text and "1 of 1 open" in r.text + + c = client.post( + "/v/open/dr_ev/claim", data={"role": "volunteer"}, cookies={SESSION_COOKIE: tok_b} + ) + assert c.status_code == 200 + got = ( + db.query(Assignment) + .filter( + Assignment.event_id == "dr_ev", + Assignment.person_id == "dr_b2", + Assignment.role == "volunteer", + ) + .first() + ) + assert got is not None and got.status == "confirmed" + + +def test_declined_does_not_consume_claim_capacity(client, db): + seed_person(db, person_id="dr_a3", org_id="dr_o3", email="a3@dr.test", roles=["volunteer"]) + tok_b = _vol(client, db, org="dr_o3", pid="dr_b3", email="b3@dr.test") + _event(db, "dr_o3") + _assign(db, eid="dr_ev", pid="dr_a3", status="declined") + c = client.post( + "/v/open/dr_ev/claim", data={"role": "volunteer"}, cookies={SESSION_COOKIE: tok_b} + ) + assert c.status_code == 200 diff --git a/web/routers/pages.py b/web/routers/pages.py index f7113394..fa8ee347 100644 --- a/web/routers/pages.py +++ b/web/routers/pages.py @@ -157,6 +157,10 @@ def _open_shifts(db: Session, person: Person) -> list[dict]: continue # already on this event — don't offer it filled: dict[str, int] = {} for a in rows: + # A declined assignment frees its slot — it must not count + # as filled, so the role re-opens for self-serve (B8). + if (a.status or "").lower() == "declined": + continue filled[a.role or ""] = filled.get(a.role or "", 0) + 1 roles = [ {"role": r, "needed": n, "remaining": n - filled.get(r, 0)} diff --git a/web/routers/partials.py b/web/routers/partials.py index 8636e7d1..fa50d368 100644 --- a/web/routers/partials.py +++ b/web/routers/partials.py @@ -230,7 +230,14 @@ def open_claim( ) if any(a.person_id == person.id for a in rows): return _open_list(request, person, db, error="You're already on this event.") - if sum(1 for a in rows if (a.role or "") == role) >= rc[role]: + if ( + sum( + 1 + for a in rows + if (a.role or "") == role and (a.status or "").lower() != "declined" + ) + >= rc[role] + ): return _open_list(request, person, db, error="That role just filled up.") db.add(