Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions tests/e2e/test_decline_reopen.py
Original file line number Diff line number Diff line change
@@ -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)
89 changes: 89 additions & 0 deletions tests/web/test_decline_reopen.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions web/routers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
9 changes: 8 additions & 1 deletion web/routers/partials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading