Coverage for app / backend / src / couchers / notifications / expo_api.py: 35%
41 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1"""Mobile push notification API clients"""
3import logging
4from typing import Any
6import requests
7import sentry_sdk
9logger = logging.getLogger(__name__)
12def send_expo_push_notification(
13 *,
14 token: str,
15 title: str,
16 body: str,
17 ios_subtitle: str | None = None,
18 data: dict[str, Any] | None = None,
19 collapse_key: str | None = None,
20) -> dict[str, Any]:
21 """Send a push notification via the Expo Push API"""
22 message: dict[str, Any] = {
23 "to": token,
24 "sound": "default",
25 "title": title,
26 "body": body,
27 "data": data or {},
28 "priority": "high",
29 "channelId": "default",
30 }
31 if ios_subtitle:
32 message["subtitle"] = ios_subtitle
34 if collapse_key:
35 message["collapseKey"] = collapse_key
37 try:
38 response = requests.post(
39 "https://exp.host/--/api/v2/push/send",
40 json=message,
41 headers={
42 "Accept": "application/json",
43 "Accept-encoding": "gzip, deflate",
44 "Content-Type": "application/json",
45 },
46 timeout=10,
47 )
48 response.raise_for_status()
49 result = response.json()
50 if not isinstance(result, dict):
51 raise ValueError(f"Expected a dict, but got {result}")
52 return result
53 except requests.exceptions.RequestException as exc:
54 logger.error("Failed to send Expo push notification: %s", exc)
55 sentry_sdk.set_tag("context", "expo_push_api")
56 sentry_sdk.set_tag("token_prefix", token[:16])
57 sentry_sdk.set_context(
58 "expo_push",
59 {
60 "title": title,
61 "body_preview": body[:120],
62 "data": data,
63 "collapse_key": collapse_key,
64 },
65 )
66 sentry_sdk.capture_exception(exc)
67 raise
70def get_expo_push_receipts(ticket_ids: list[str]) -> dict[str, Any]:
71 """Fetch push receipts from Expo API.
73 Expo push notifications are a two-phase delivery system:
74 1. Send notification → get ticket ID (immediate)
75 2. Check receipt → get actual delivery status (after ~15 minutes)
77 Args:
78 ticket_ids: List of ticket IDs to check (max 1000 per request)
80 Returns:
81 Dict mapping ticket_id -> receipt data, where receipt data contains:
82 - status: "ok" or "error"
83 - For errors: details.error (e.g., "DeviceNotRegistered", "MessageTooBig")
84 """
85 if not ticket_ids:
86 return {}
88 try:
89 response = requests.post(
90 "https://exp.host/--/api/v2/push/getReceipts",
91 json={"ids": ticket_ids},
92 headers={
93 "Accept": "application/json",
94 "Content-Type": "application/json",
95 },
96 timeout=10,
97 )
98 response.raise_for_status()
99 result = response.json().get("data", {})
100 if not isinstance(result, dict): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 raise ValueError(f"Expected a dict, but got {result}")
102 return result
103 except requests.exceptions.RequestException as exc:
104 logger.error("Failed to fetch Expo push receipts: %s", exc)
105 sentry_sdk.set_tag("context", "expo_push_receipts")
106 sentry_sdk.set_context("expo_receipts", {"ticket_count": len(ticket_ids)})
107 sentry_sdk.capture_exception(exc)
108 raise