diff --git a/infra/lambda/poller/db.py b/infra/lambda/poller/db.py index 31c70a4..555df83 100644 --- a/infra/lambda/poller/db.py +++ b/infra/lambda/poller/db.py @@ -553,20 +553,34 @@ def build_active_plan_ride_index( item.get("plan_window"), now_et ): continue - active_plans.append({ - "user_id": user_id, - "plan_id": plan_id, - "park_key": item.get("park_key"), - # park_name isn't stored on the plan row, but it's - # derivable from park_key in the handler via the same - # PARK_NAME lookup the notifier uses. Leaving the slot - # here for clarity. - }) + # Alert recipients (2026-07-03): the partition owner is always + # implicit, plus any opted-in family members from the row's + # alert_subscribers String Set (ids with USER#/PROFILE + # rows — see set_plan_alert_subscription in the MCP). Absent + # attribute = owner-only, the pre-feature behavior. Each + # recipient gets their own index/active_plans entries; the + # weather path's per-user dedup + per-(user, plan) cooldowns + # already handle the rest. + subscribers = item.get("alert_subscribers") or set() + recipients = [user_id] + sorted( + s for s in subscribers if s and s != user_id + ) + for recipient in recipients: + active_plans.append({ + "user_id": recipient, + "plan_id": plan_id, + "park_key": item.get("park_key"), + # park_name isn't stored on the plan row, but it's + # derivable from park_key in the handler via the same + # PARK_NAME lookup the notifier uses. Leaving the slot + # here for clarity. + }) for ride in item.get("ride_sequence", []) or []: ride_id = ride.get("ride_id") ride_name = ride.get("ride_name") for key in filter(None, (ride_id, (ride_name or "").lower())): - index.setdefault(key, []).append((user_id, plan_id)) + for recipient in recipients: + index.setdefault(key, []).append((recipient, plan_id)) last_evaluated_key = resp.get("LastEvaluatedKey") if not last_evaluated_key: break diff --git a/infra/lambda/poller/tests/test_db.py b/infra/lambda/poller/tests/test_db.py index 93cdf0d..a541ecb 100644 --- a/infra/lambda/poller/tests/test_db.py +++ b/infra/lambda/poller/tests/test_db.py @@ -444,3 +444,49 @@ def test_get_user_favorites_reads_all_pages(self): }) favs = db.get_user_favorites_for_park("megan", "magic_kingdom") assert favs == {"r1", "r2", "r3"} + + +# ── Plan alert subscribers fanout (2026-07-03) ── + +class TestPlanAlertSubscribersFanout: + """alert_subscribers on a PLAN# row adds recipients to BOTH fanout + surfaces (per-ride index for DOWN/UP/low-wait, active_plans for + weather). Absent attribute = owner-only — the pre-feature behavior.""" + + def setup_method(self): + self.stub = _StubTable() + _swap_in_stub(self.stub) + + def _put_plan(self, plan_id, subscribers=None): + item = { + "PK": "USER#megan", + "SK": f"PLAN#{plan_id}", + "planned_for_date": "2026-06-23", + "outcome_recorded": False, + "active": True, + "park_key": "magic_kingdom", + "ride_sequence": [{"ride_id": "sm", "ride_name": "Space Mountain"}], + } + if subscribers: + item["alert_subscribers"] = set(subscribers) + self.stub.put_item(Item=item) + + def test_absent_attribute_is_owner_only(self): + self._put_plan("p1") + index, active_plans = db.build_active_plan_ride_index("2026-06-23") + assert index["sm"] == [("megan", "p1")] + assert [p["user_id"] for p in active_plans] == ["megan"] + + def test_subscribers_expand_both_surfaces(self): + self._put_plan("p1", subscribers={"sub-jim", "sub-sis"}) + index, active_plans = db.build_active_plan_ride_index("2026-06-23") + # Ride index: owner + both subscribers, owner first. + assert set(index["sm"]) == {("megan", "p1"), ("sub-jim", "p1"), ("sub-sis", "p1")} + assert index["sm"][0] == ("megan", "p1") + # Weather surface: one active_plans entry per recipient. + assert {p["user_id"] for p in active_plans} == {"megan", "sub-jim", "sub-sis"} + + def test_owner_in_subscribers_not_duplicated(self): + self._put_plan("p1", subscribers={"megan", "sub-jim"}) + index, _ = db.build_active_plan_ride_index("2026-06-23") + assert sorted(index["sm"]) == [("megan", "p1"), ("sub-jim", "p1")] diff --git a/mcp/_tool_impls.py b/mcp/_tool_impls.py index 37187b2..233545c 100644 --- a/mcp/_tool_impls.py +++ b/mcp/_tool_impls.py @@ -135,6 +135,11 @@ def _convert_decimals(obj: Any) -> Any: return {k: _convert_decimals(v) for k, v in obj.items()} if isinstance(obj, list): return [_convert_decimals(v) for v in obj] + if isinstance(obj, set): + # DynamoDB String Sets (e.g. alert_subscribers) come back as Python + # sets, which aren't JSON-serializable — a raw row in a tool return + # would crash the MCP runtime. Sorted list keeps output stable. + return sorted(_convert_decimals(v) for v in obj) return obj @@ -2232,6 +2237,7 @@ def _build_plan_item( active: bool = False, activated_at: str | None = None, created_by: str | None = None, + alert_subscribers: set[str] | None = None, ) -> dict[str, Any]: """Assemble a PLAN# row. Shared by record_plan (single day, often same-day + active) and create_trip (one dormant row per trip day). @@ -2247,8 +2253,15 @@ def _build_plan_item( - `created_by`: attribution label (friendly user id). Defaults to user_id. In the shared-trip model multiple people write to one partition, so we stamp who recorded each row. + - `alert_subscribers` (2026-07-03): DDB String Set of ADDITIONAL + alert recipients (ids with a USER#/PROFILE row — Cognito subs + for family members). The partition owner is IMPLICIT and always + alerted; absent attribute = owner-only, exactly the pre-feature + behavior (no migration). Omitted when empty (DDB rejects empty + sets). Mutated only via atomic ADD/DELETE (see + set_plan_alert_subscription) so web + MCP edits can't race. """ - return { + item = { "PK": f"USER#{user_id}", "SK": f"PLAN#{plan_ts}", "park_key": park_key, @@ -2271,6 +2284,64 @@ def _build_plan_item( "outcome_recorded": False, "ttl": _plan_pending_ttl(planned_for_date), } + if alert_subscribers: + item["alert_subscribers"] = set(alert_subscribers) + return item + + +def _resolve_alert_member( + table, member: str, friendly_to_sub: dict[str, str] | None = None +) -> tuple[str | None, bool]: + """Resolve a member label to the profile id the poller alerts on. + + Tries `member` as-given (a Cognito sub, or a legacy friendly id with + its own profile row), then via a friendly-name→sub map (available on + the HTTP transport from MCP_SUB_USER_MAP). The poller looks up + Pushover keys at USER#/PROFILE, so an id only "works" if that row + exists — family members create it by signing into the dashboard and + saving /me once. + + Returns (resolved_id, has_pushover_key); (None, False) when no + profile row exists under any candidate id. + """ + candidates = [member] + if friendly_to_sub: + mapped = friendly_to_sub.get(member.strip().lower()) + if mapped: + candidates.append(mapped) + for cand in candidates: + row = table.get_item( + Key={"PK": f"USER#{cand}", "SK": "PROFILE"} + ).get("Item") + if row: + return cand, bool(row.get("pushover_user_key")) + return None, False + + +def _apply_alert_subscription( + table, user_id: str, member_id: str, subscribed: bool, + plan_rows: list[dict], +) -> list[str]: + """Atomically ADD/DELETE `member_id` in each plan row's + alert_subscribers String Set. + + Set-level ADD/DELETE (not read-modify-write) so a concurrent MCP plan + edit or web toggle can't lose the change — and it never touches the + attributes the plan-edit tools rewrite. DELETE removing the last + member removes the attribute entirely, which reads back as + owner-only (the default). Returns the affected planned_for_dates. + """ + op = "ADD" if subscribed else "DELETE" + updated: list[str] = [] + for r in plan_rows: + table.update_item( + Key={"PK": f"USER#{user_id}", "SK": r["SK"]}, + UpdateExpression=f"{op} alert_subscribers :m", + ExpressionAttributeValues={":m": {member_id}}, + ConditionExpression="attribute_exists(PK)", + ) + updated.append(r.get("planned_for_date") or r["SK"]) + return updated def _bias_confidence(n: int) -> str: diff --git a/mcp/server.py b/mcp/server.py index 7d07b32..7a04ddb 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -54,6 +54,7 @@ _TRIP_BUFFER_DAYS, _WDW_LAT, _WDW_LON, + _apply_alert_subscription, _aws_error_payload, _bias_confidence, _build_plan_item, @@ -76,6 +77,7 @@ _park_state_rows_via_gsi, _plan_pending_ttl, _pop_ride_from_sequence, + _resolve_alert_member, _today_et_date_iso, get_planning_context, ) @@ -1605,6 +1607,12 @@ def record_plan( item["plan_window"] = prior.get("plan_window") if active and prior.get("active") and prior.get("activated_at"): item["activated_at"] = prior.get("activated_at") + # Same wipe hazard for alert opt-ins: family members subscribed via + # set_plan_alert_subscription (or the web toggle) must survive a + # re-record of the day. + prior_subs = prior.get("alert_subscribers") + if prior_subs: + item["alert_subscribers"] = set(prior_subs) try: table.put_item(Item=_floats_to_decimals(item)) @@ -2490,6 +2498,111 @@ def record_plan_outcome( } +@mcp.tool() +def set_plan_alert_subscription( + member: str, + subscribed: bool = True, + trip_id: str | None = None, + date: str | None = None, + user_id: str = _DEFAULT_USER_ID, +) -> dict[str, Any]: + """Opt a family member IN (or out) of the disruption/weather/low-wait + alert pushes for a trip or a single day's plan. + + By default only the shared-partition owner receives plan alerts (the + owner is always subscribed and can't be removed). This adds `member` + as an additional recipient on the matching plan day rows — they'll get + the same DOWN / BACK UP / storm / low-wait pushes for those days. + + Args: + member: Who to subscribe. Their Cognito sub (preferred), or any id + that has a USER#/PROFILE row. The member must have signed + into the dashboard and saved /me (that's where their Pushover + key lives) — if they haven't, this errors with instructions. + subscribed: True to opt in (default), False to opt out. + trip_id: Apply to EVERY un-recorded day of this trip. + date: Apply to the single plan for this date (YYYY-MM-DD). + Provide trip_id or date (or both to filter to one trip day). + user_id: Shared-partition owner, default "megan". + + Returns: + Dict with member (resolved id), subscribed, days_updated (list of + planned_for_dates), and a warning when the member's profile has no + Pushover key yet (subscription stored, but no pushes until they + add one at /me). + """ + if not trip_id and not date: + return { + "error": "Provide trip_id and/or date", + "error_message": "Say which trip (trip_id) or day (date) to apply to.", + } + target_date = None + if date: + try: + target_date = datetime.fromisoformat(date).date().isoformat() + except ValueError: + return { + "error": "Invalid date", + "error_message": f"Could not parse '{date}'. Use YYYY-MM-DD.", + } + + try: + table = _ddb_table() + member_id, has_key = _resolve_alert_member(table, member) + if member_id is None: + return { + "error": "Member has no profile", + "error_message": ( + f"No USER#/PROFILE row found for {member!r}. They need " + "to sign into the dashboard once and save /me (name + " + "Pushover key) — then subscribe them by their id." + ), + } + if member_id == user_id: + return { + "member": member_id, + "subscribed": True, + "days_updated": [], + "note": "The plan owner always receives alerts — nothing to change.", + } + rows = [ + r for r in ( + _convert_decimals(x) + for x in _query_user_prefix(table, user_id, "PLAN#") + ) + if not r.get("outcome_recorded") + and (trip_id is None or r.get("trip_id") == trip_id) + and (target_date is None or r.get("planned_for_date") == target_date) + ] + if not rows: + return { + "error": "No matching plans", + "error_message": ( + f"No un-recorded plan rows matched trip_id={trip_id!r} " + f"date={target_date!r}." + ), + } + days = _apply_alert_subscription(table, user_id, member_id, subscribed, rows) + except Exception as e: + err = _aws_error_payload(e) + return err if err is not None else { + "error": "Subscription update failed", + "error_message": str(e), + } + + out: dict[str, Any] = { + "member": member_id, + "subscribed": subscribed, + "days_updated": sorted(days), + } + if subscribed and not has_key: + out["warning"] = ( + "Subscription stored, but this member's profile has no Pushover " + "key — they won't receive pushes until they add one at /me." + ) + return out + + @mcp.tool() def mark_ride_complete( plan_id: str, diff --git a/mcp/server_http.py b/mcp/server_http.py index 9e0f156..f3dceb8 100644 --- a/mcp/server_http.py +++ b/mcp/server_http.py @@ -112,6 +112,7 @@ _WDW_LAT, _WDW_LON, _all_park_state_rows_via_gsi, + _apply_alert_subscription, _aws_error_payload, _bias_confidence, _build_plan_item, @@ -134,6 +135,7 @@ _park_state_rows_via_gsi, _plan_pending_ttl, _pop_ride_from_sequence, + _resolve_alert_member, _today_et_date_iso, get_planning_context, ) @@ -385,6 +387,18 @@ def _created_by_from_context() -> str: return _SUB_USER_MAP.get(sub, sub) +def _creator_alert_seed() -> set[str] | None: + """Default alert_subscribers for a NEW plan row: the verified caller's + sub, when the caller isn't the shared-partition owner — so a family + member's plans alert them without a separate opt-in step. The owner is + an implicit recipient and is never stored (see _build_plan_item). + """ + sub = _authenticated_sub.get() + if not sub or _SUB_USER_MAP.get(sub) == _SHARED_USER_ID: + return None + return {sub} + + # ─── FastMCP server + tools ───────────────────────────────────────── # `stateless_http=True` is REQUIRED for Lambda: each request needs to # be self-contained because the Lambda container doesn't preserve @@ -1594,6 +1608,7 @@ def record_plan( active=active, activated_at=activated_at, created_by=created_by, + alert_subscribers=_creator_alert_seed(), ) if prior is not None: @@ -1609,6 +1624,13 @@ def record_plan( item["plan_window"] = prior.get("plan_window") if active and prior.get("active") and prior.get("activated_at"): item["activated_at"] = prior.get("activated_at") + # Same wipe hazard for alert opt-ins: subscribed family members + # must survive a re-record of the day. Merge prior subs with any + # fresh seed (e.g. a different creator re-recording). + prior_subs = set(prior.get("alert_subscribers") or ()) + merged_subs = prior_subs | set(item.get("alert_subscribers") or ()) + if merged_subs: + item["alert_subscribers"] = merged_subs try: table.put_item(Item=_floats_to_decimals(item)) @@ -1744,6 +1766,7 @@ def create_trip(name: str, days: list[dict[str, Any]]) -> dict[str, Any]: plan_window=d["plan_window"], active=False, created_by=created_by, + alert_subscribers=_creator_alert_seed(), ) batch.put_item(Item=_floats_to_decimals(item)) day_results.append({"date": d["date"], "park_key": d["park_key"], @@ -2296,6 +2319,116 @@ def record_plan_outcome( } +@mcp.tool() +def set_plan_alert_subscription( + member: str, + subscribed: bool = True, + trip_id: str | None = None, + date: str | None = None, +) -> dict[str, Any]: + """Opt a family member IN (or out) of the disruption/weather/low-wait + alert pushes for a trip or a single day's plan. + + By default only the shared-partition owner receives plan alerts (the + owner is always subscribed and can't be removed); a plan's creator is + auto-subscribed at record time. This adds `member` as an additional + recipient on the matching plan day rows — same DOWN / BACK UP / storm / + low-wait pushes. + + Args: + member: Who to subscribe — a family member's name as configured + (e.g. "jim"), or their Cognito sub, or any id with a + USER#/PROFILE row. The member must have signed into the + dashboard and saved /me once (that's where their Pushover key + lives) — if they haven't, this errors with instructions. + subscribed: True to opt in (default), False to opt out. + trip_id: Apply to EVERY un-recorded day of this trip. + date: Apply to the single plan for this date (YYYY-MM-DD). + Provide trip_id or date (or both to filter to one trip day). + + Returns: + Dict with member (resolved id), subscribed, days_updated, and a + warning when the member's profile has no Pushover key yet. + """ + if not trip_id and not date: + return { + "error": "Provide trip_id and/or date", + "error_message": "Say which trip (trip_id) or day (date) to apply to.", + } + target_date = None + if date: + try: + target_date = datetime.fromisoformat(date).date().isoformat() + except ValueError: + return { + "error": "Invalid date", + "error_message": f"Could not parse '{date}'. Use YYYY-MM-DD.", + } + + try: + table = _ddb_table() + # Friendly-name → sub map (reverse of MCP_SUB_USER_MAP) so "jim" + # resolves to the sub whose /me profile holds his Pushover key. + friendly_to_sub = { + friendly.strip().lower(): sub + for sub, friendly in _SUB_USER_MAP.items() + } + member_id, has_key = _resolve_alert_member(table, member, friendly_to_sub) + if member_id is None: + return { + "error": "Member has no profile", + "error_message": ( + f"No USER#/PROFILE row found for {member!r}. They need " + "to sign into the dashboard once and save /me (name + " + "Pushover key) first." + ), + } + if member_id == _SHARED_USER_ID or _SUB_USER_MAP.get(member_id) == _SHARED_USER_ID: + return { + "member": member_id, + "subscribed": True, + "days_updated": [], + "note": "The plan owner always receives alerts — nothing to change.", + } + rows = [ + r for r in ( + _convert_decimals(x) for x in _query_shared_prefix(table, "PLAN#") + ) + if not r.get("outcome_recorded") + and (trip_id is None or r.get("trip_id") == trip_id) + and (target_date is None or r.get("planned_for_date") == target_date) + ] + if not rows: + return { + "error": "No matching plans", + "error_message": ( + f"No un-recorded plan rows matched trip_id={trip_id!r} " + f"date={target_date!r}." + ), + } + days = _apply_alert_subscription( + table, _SHARED_USER_ID, member_id, subscribed, rows + ) + except Exception as e: + err = _aws_error_payload(e) + return err if err is not None else { + "error": "Subscription update failed", + "error_message": str(e), + } + + out: dict[str, Any] = { + "member": member_id, + "subscribed": subscribed, + "days_updated": sorted(days), + } + if subscribed and not has_key: + out["warning"] = ( + "Subscription stored, but this member's profile has no Pushover " + "key — they won't receive pushes until they add one at /me." + ) + return out + + @mcp.tool() def mark_ride_complete( plan_id: str, diff --git a/mcp/tests/test_server_http_trip.py b/mcp/tests/test_server_http_trip.py index a8c9d08..31e8362 100644 --- a/mcp/tests/test_server_http_trip.py +++ b/mcp/tests/test_server_http_trip.py @@ -264,3 +264,44 @@ def test_upcoming_trip_derives_added_day(self, stub, as_user): assert out["found"] is True assert "2099-06-25" in [d["date"] for d in out["days"]] assert out["end_date"] == "2099-06-25" + + +class TestAlertSubscriberSeed: + """New plan rows auto-subscribe their creator (by sub) when the + creator isn't the shared-partition owner; the owner stays implicit + (never stored).""" + + def test_non_owner_creator_is_seeded(self, stub, as_user): + as_user("sub-jim") + rec = s.record_plan("MK", [], planned_for_date="2099-07-01") + row = stub.items[("USER#megan", f"PLAN#{rec['plan_id']}")] + assert row["alert_subscribers"] == {"sub-jim"} + + def test_owner_creator_not_stored(self, stub, as_user): + as_user("sub-megan") + rec = s.record_plan("MK", [], planned_for_date="2099-07-01") + row = stub.items[("USER#megan", f"PLAN#{rec['plan_id']}")] + assert "alert_subscribers" not in row + + def test_create_trip_seeds_every_day(self, stub, as_user): + as_user("sub-jim") + s.create_trip("Trip", [ + {"date": "2099-07-01", "park": "MK"}, + {"date": "2099-07-02", "park": "EPCOT"}, + ]) + plan_rows = [v for (p, sk), v in stub.items.items() if sk.startswith("PLAN#")] + assert len(plan_rows) == 2 + for r in plan_rows: + assert r["alert_subscribers"] == {"sub-jim"} + + def test_upsert_merges_prior_subs_with_new_seed(self, stub, as_user): + # Jim records a day (seeded sub-jim); Megan re-records the same + # day — prior subs must survive the row rebuild. + as_user("sub-jim") + rec = s.record_plan("MK", [], planned_for_date="2099-07-01", trip_id="t1") + as_user("sub-megan") + rec2 = s.record_plan("MK", [{"ride_name": "Space", "ride_id": "sm"}], + planned_for_date="2099-07-01", trip_id="t1") + assert rec2["plan_id"] == rec["plan_id"] + row = stub.items[("USER#megan", f"PLAN#{rec['plan_id']}")] + assert row["alert_subscribers"] == {"sub-jim"} diff --git a/mcp/tests/test_trip_planner.py b/mcp/tests/test_trip_planner.py index 3060f1d..559e5a8 100644 --- a/mcp/tests/test_trip_planner.py +++ b/mcp/tests/test_trip_planner.py @@ -69,6 +69,19 @@ def update_item(self, Key, UpdateExpression=None, ExpressionAttributeValues=None attr = lhs.strip() attr = names.get(attr, attr) # resolve #ttl etc. item[attr] = vals[rhs.strip()] + elif expr.upper().startswith(("ADD ", "DELETE ")): + # Atomic set ops (alert_subscribers). DDB semantics: ADD + # creates the set if absent; DELETE removing the last member + # removes the attribute entirely. + op, rest = expr.split(" ", 1) + attr, rhs = rest.strip().split(" ") + val = set(vals[rhs.strip()]) + cur = set(item.get(attr) or set()) + cur = cur | val if op.upper() == "ADD" else cur - val + if cur: + item[attr] = cur + else: + item.pop(attr, None) if ReturnValues == "ALL_NEW": return {"Attributes": dict(item)} return {} @@ -635,3 +648,87 @@ def test_upcoming_trip_dedupes_duplicate_date(self, stub): assert len(out["days"]) == 1 # the date shows ONCE assert out["days"][0]["active"] is True # active row preferred assert out["days"][0]["ride_count"] == 2 + + +# ─── set_plan_alert_subscription (2026-07-03) ─────────────────────── + + +class TestPlanAlertSubscription: + def _seed_profile(self, stub, member_id, pushover_key="pk-123"): + item = {"PK": f"USER#{member_id}", "SK": "PROFILE"} + if pushover_key: + item["pushover_user_key"] = pushover_key + stub.put_item(Item=item) + + def test_subscribe_member_trip_wide(self, stub): + self._seed_profile(stub, "sub-sis") + trip = server.create_trip("Trip", [ + {"date": _future(10), "park": "MK"}, + {"date": _future(11), "park": "EPCOT"}, + ]) + out = server.set_plan_alert_subscription("sub-sis", trip_id=trip["trip_id"]) + assert "error" not in out and "warning" not in out + assert sorted(out["days_updated"]) == [_future(10), _future(11)] + plan_rows = [v for (p, s), v in stub.items.items() if s.startswith("PLAN#")] + for r in plan_rows: + assert r["alert_subscribers"] == {"sub-sis"} + + def test_unsubscribe_removes_attribute(self, stub): + self._seed_profile(stub, "sub-sis") + trip = server.create_trip("Trip", [{"date": _future(10), "park": "MK"}]) + server.set_plan_alert_subscription("sub-sis", trip_id=trip["trip_id"]) + out = server.set_plan_alert_subscription( + "sub-sis", subscribed=False, trip_id=trip["trip_id"] + ) + assert out["subscribed"] is False + row = next(v for (p, s), v in stub.items.items() if s.startswith("PLAN#")) + assert "alert_subscribers" not in row # last member out → attr gone + + def test_single_date_only_touches_that_day(self, stub): + self._seed_profile(stub, "sub-sis") + trip = server.create_trip("Trip", [ + {"date": _future(10), "park": "MK"}, + {"date": _future(11), "park": "EPCOT"}, + ]) + out = server.set_plan_alert_subscription("sub-sis", date=_future(11)) + assert out["days_updated"] == [_future(11)] + rows = {v["planned_for_date"]: v for (p, s), v in stub.items.items() + if s.startswith("PLAN#")} + assert "alert_subscribers" not in rows[_future(10)] + assert rows[_future(11)]["alert_subscribers"] == {"sub-sis"} + + def test_member_without_profile_errors(self, stub): + server.create_trip("Trip", [{"date": _future(10), "park": "MK"}]) + out = server.set_plan_alert_subscription("nobody", date=_future(10)) + assert out["error"] == "Member has no profile" + assert "/me" in out["error_message"] + + def test_owner_is_noop(self, stub): + self._seed_profile(stub, "megan") + server.create_trip("Trip", [{"date": _future(10), "park": "MK"}]) + out = server.set_plan_alert_subscription("megan", date=_future(10)) + assert out["days_updated"] == [] + assert "always receives" in out["note"] + + def test_missing_pushover_key_warns_but_stores(self, stub): + self._seed_profile(stub, "sub-sis", pushover_key=None) + server.create_trip("Trip", [{"date": _future(10), "park": "MK"}]) + out = server.set_plan_alert_subscription("sub-sis", date=_future(10)) + assert "Pushover" in out["warning"] + row = next(v for (p, s), v in stub.items.items() if s.startswith("PLAN#")) + assert row["alert_subscribers"] == {"sub-sis"} + + def test_requires_trip_or_date(self, stub): + out = server.set_plan_alert_subscription("sub-sis") + assert "Provide trip_id and/or date" in out["error"] + + def test_upsert_preserves_subscribers(self, stub): + # The calibration-wipe bug class: a same-day re-record must not + # drop opted-in members (put_item replaces the whole row). + self._seed_profile(stub, "sub-sis") + server.record_plan("MK", [], planned_for_date=_future(10), trip_id="t1") + server.set_plan_alert_subscription("sub-sis", date=_future(10)) + server.record_plan("MK", [{"ride_name": "Space", "ride_id": "sm"}], + planned_for_date=_future(10), trip_id="t1") + row = next(v for (p, s), v in stub.items.items() if s.startswith("PLAN#")) + assert row["alert_subscribers"] == {"sub-sis"}