From db9f19ab0038ac572e26a2bd9d387dc75ab1b8f2 Mon Sep 17 00:00:00 2001 From: AutumnQiu99 Date: Sat, 25 Apr 2026 13:17:52 -0400 Subject: [PATCH 1/2] refactor(validators): remove E.164 format validation for optional phone numbers --- backend/validators.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/validators.py b/backend/validators.py index e7630e4..8ff9a21 100644 --- a/backend/validators.py +++ b/backend/validators.py @@ -69,8 +69,6 @@ def optional_phone(value: Any) -> str | None: phone = value.strip() if not phone: return None - if not phone.startswith("+") or not phone[1:].isdigit(): - raise APIError(422, "phone must use E.164 format") return phone From f72f89727642dbf7fa6c9646035523cc59cc23da Mon Sep 17 00:00:00 2001 From: AutumnQiu99 Date: Sat, 25 Apr 2026 20:34:42 -0400 Subject: [PATCH 2/2] feat(validators): add E.164 phone validation and update notification preferences - Introduced `is_e164_phone` function to validate phone numbers in E.164 format. - Updated user preferences and member service to include `hasSmsPhone` flag. - Enhanced SMS channel to skip sending messages for invalid phone numbers. - Modified tests to cover new validation logic and preferences handling. --- backend/controllers/session_controller.py | 1 + backend/services/member_service.py | 1 + .../notifications/channels/sms_channel.py | 14 +++---- backend/services/user_preferences.py | 12 +++++- backend/tests/unit/test_admin_session_auth.py | 17 +++++++- backend/tests/unit/test_member_service.py | 42 +++++++++++++++++++ backend/tests/unit/test_sms_channel.py | 23 ++++++++++ backend/tests/unit/test_user_preferences.py | 33 +++++++++++++++ backend/tests/unit/test_user_service.py | 37 ++++++++++++++++ backend/tests/unit/test_validators.py | 14 ++++++- backend/validators.py | 13 ++++++ frontend/src/App.tsx | 1 + frontend/src/lib/api/contracts/types.ts | 3 ++ frontend/src/lib/member-preferences.ts | 16 ++++--- frontend/src/lib/store.ts | 2 + .../src/pages/member/NotificationSettings.tsx | 13 ++++-- frontend/src/test/app-auth.test.tsx | 1 + frontend/src/test/member-preferences.test.ts | 33 +++++++++++++++ 18 files changed, 255 insertions(+), 21 deletions(-) diff --git a/backend/controllers/session_controller.py b/backend/controllers/session_controller.py index 47aa679..313ee1c 100644 --- a/backend/controllers/session_controller.py +++ b/backend/controllers/session_controller.py @@ -158,6 +158,7 @@ def session_me(): "emailNotifications": normalized["emailNotifications"], "smsNotifications": normalized["smsNotifications"], "hasPhone": normalized["hasPhone"], + "hasSmsPhone": normalized["hasSmsPhone"], } ), 200, diff --git a/backend/services/member_service.py b/backend/services/member_service.py index 5d1a88d..7868cd0 100644 --- a/backend/services/member_service.py +++ b/backend/services/member_service.py @@ -45,4 +45,5 @@ def update_member_notification_preferences( "emailNotifications": normalized["emailNotifications"], "smsNotifications": normalized["smsNotifications"], "hasPhone": normalized["hasPhone"], + "hasSmsPhone": normalized["hasSmsPhone"], } diff --git a/backend/services/notifications/channels/sms_channel.py b/backend/services/notifications/channels/sms_channel.py index 5c8d3d1..eb05e9c 100644 --- a/backend/services/notifications/channels/sms_channel.py +++ b/backend/services/notifications/channels/sms_channel.py @@ -4,6 +4,7 @@ from typing import Union from services.notifications.providers.sms_provider import SMSProvider +from validators import is_e164_phone from services.notifications.types import ChannelResult, SpecialCaseNotificationPayload, WeeklySummaryData, WeeklySummaryNotificationPayload @@ -14,9 +15,12 @@ def __init__(self, provider: SMSProvider) -> None: self.provider = provider def send(self, payload: WeeklySummaryNotificationPayload | SpecialCaseNotificationPayload) -> ChannelResult: - phone = self._resolve_phone(payload) - if not phone: + raw_phone = payload["user"].get("phone") + if not isinstance(raw_phone, str) or not raw_phone.strip(): return {"channel": self.channel, "status": "skipped", "error": "missing phone"} + phone = raw_phone.strip() + if not is_e164_phone(phone): + return {"channel": self.channel, "status": "skipped", "error": "invalid phone for SMS"} if "summary" in payload: body = self._build_weekly_summary_body(payload) @@ -37,12 +41,6 @@ def send(self, payload: WeeklySummaryNotificationPayload | SpecialCaseNotificati "error": str(exc), } - def _resolve_phone(self, payload: WeeklySummaryNotificationPayload | SpecialCaseNotificationPayload) -> str: - raw_phone = payload["user"].get("phone") - if not isinstance(raw_phone, str): - return "" - return raw_phone.strip() - def _build_weekly_summary_body(self, payload: WeeklySummaryNotificationPayload) -> str: summary = payload["summary"] week_start = self._format_summary_date(summary["weekStart"]) diff --git a/backend/services/user_preferences.py b/backend/services/user_preferences.py index 2c92806..ba5398d 100644 --- a/backend/services/user_preferences.py +++ b/backend/services/user_preferences.py @@ -3,6 +3,7 @@ from typing import Any from errors import APIError +from validators import is_e164_phone UNSET = object() _EMAIL_PREF = "email" @@ -71,14 +72,21 @@ def normalize_effective_notification_state( effective_prefs.discard(_SMS_PREF) has_phone = effective_phone is not None - if not has_phone: + has_sms_phone = bool(effective_phone and is_e164_phone(effective_phone)) + if not has_sms_phone: if explicit_sms_enable: - raise APIError(400, "SMS notifications require a valid phone number") + if not has_phone: + raise APIError(400, "SMS notifications require a valid phone number") + raise APIError( + 400, + "SMS notifications require a phone number in E.164 format (for example +15551234567)", + ) effective_prefs.discard(_SMS_PREF) return { "phone": effective_phone, "hasPhone": has_phone, + "hasSmsPhone": has_sms_phone, "notifPrefs": _to_ordered_storage_prefs(effective_prefs), "emailNotifications": _EMAIL_PREF in effective_prefs, "smsNotifications": _SMS_PREF in effective_prefs, diff --git a/backend/tests/unit/test_admin_session_auth.py b/backend/tests/unit/test_admin_session_auth.py index 5868c78..797c8c3 100644 --- a/backend/tests/unit/test_admin_session_auth.py +++ b/backend/tests/unit/test_admin_session_auth.py @@ -454,6 +454,7 @@ def test_session_me_returns_profile(self): "emailNotifications": True, "smsNotifications": False, "hasPhone": True, + "hasSmsPhone": True, }, ) @@ -493,7 +494,13 @@ def test_member_preferences_patches_boolean_toggle(self): with self.client.session_transaction() as sess: sess["user_id"] = user_id - expected = {"id": user_id, "emailNotifications": True, "smsNotifications": False, "hasPhone": True} + expected = { + "id": user_id, + "emailNotifications": True, + "smsNotifications": False, + "hasPhone": True, + "hasSmsPhone": True, + } with patch("controllers.auth_guard.find_user", return_value=user_doc), patch( "controllers.member_controller.update_member_notification_preferences", return_value=expected ) as prefs_mock: @@ -521,7 +528,13 @@ def test_member_preferences_accepts_partial_sms_patch(self): with self.client.session_transaction() as sess: sess["user_id"] = user_id - expected = {"id": user_id, "emailNotifications": True, "smsNotifications": True, "hasPhone": True} + expected = { + "id": user_id, + "emailNotifications": True, + "smsNotifications": True, + "hasPhone": True, + "hasSmsPhone": True, + } with patch("controllers.auth_guard.find_user", return_value=user_doc), patch( "controllers.member_controller.update_member_notification_preferences", return_value=expected ) as prefs_mock: diff --git a/backend/tests/unit/test_member_service.py b/backend/tests/unit/test_member_service.py index 84b6d77..b91ffe4 100644 --- a/backend/tests/unit/test_member_service.py +++ b/backend/tests/unit/test_member_service.py @@ -31,6 +31,7 @@ def test_update_member_preferences_partial_patch_leaves_omitted_fields_unchanged "emailNotifications": True, "smsNotifications": True, "hasPhone": True, + "hasSmsPhone": True, }, ) @@ -67,6 +68,47 @@ def test_update_member_preferences_returns_email_sms_and_has_phone(self): "emailNotifications": True, "smsNotifications": False, "hasPhone": False, + "hasSmsPhone": False, + }, + ) + + def test_update_member_preferences_rejects_sms_when_phone_is_not_e164(self): + user = {"_id": ObjectId(), "notifPrefs": ["email"], "phone": "4125551234"} + + with patch("services.member_service.update_notif_prefs") as update_mock: + with self.assertRaises(APIError) as ctx: + update_member_notification_preferences( + user=user, + sms_notifications=True, + ) + + self.assertEqual(ctx.exception.status_code, 400) + self.assertEqual( + ctx.exception.message, + "SMS notifications require a phone number in E.164 format (for example +15551234567)", + ) + update_mock.assert_not_called() + + def test_update_member_preferences_returns_has_sms_phone_false_for_naive_phone(self): + user_id = ObjectId() + user = {"_id": user_id, "notifPrefs": ["email"], "phone": "4125551234"} + + with patch("services.member_service.update_notif_prefs") as update_mock: + response = update_member_notification_preferences( + user=user, + email_notifications=True, + ) + + update_mock.assert_called_once() + self.assertEqual(update_mock.call_args.args[1], ["email"]) + self.assertEqual( + response, + { + "id": str(user_id), + "emailNotifications": True, + "smsNotifications": False, + "hasPhone": True, + "hasSmsPhone": False, }, ) diff --git a/backend/tests/unit/test_sms_channel.py b/backend/tests/unit/test_sms_channel.py index 80266f5..abee5dc 100644 --- a/backend/tests/unit/test_sms_channel.py +++ b/backend/tests/unit/test_sms_channel.py @@ -61,6 +61,29 @@ def test_send_weekly_summary_returns_skipped_when_phone_missing(self): self.assertIn("missing phone", result["error"]) self.assertEqual(provider.calls, []) + def test_send_returns_skipped_when_phone_is_not_e164(self): + provider = FakeSMSProvider() + channel = SMSChannel(provider) + + result = channel.send( + { + "user": { + "id": str(ObjectId()), + "email": "member@example.com", + "fullname": "Member User", + "phone": "4125551234", + }, + "triggeredBy": "admin", + "templateType": "mail-arrived", + "mailRequest": None, + } + ) + + self.assertEqual(result["channel"], "sms") + self.assertEqual(result["status"], "skipped") + self.assertIn("invalid phone for SMS", result["error"]) + self.assertEqual(provider.calls, []) + def test_send_special_case_formats_body_and_calls_provider(self): provider = FakeSMSProvider() channel = SMSChannel(provider) diff --git a/backend/tests/unit/test_user_preferences.py b/backend/tests/unit/test_user_preferences.py index e83be00..94ad067 100644 --- a/backend/tests/unit/test_user_preferences.py +++ b/backend/tests/unit/test_user_preferences.py @@ -23,6 +23,7 @@ def test_normalize_effective_preferences_merges_current_and_patch(self): self.assertTrue(result["emailNotifications"]) self.assertTrue(result["smsNotifications"]) self.assertTrue(result["hasPhone"]) + self.assertTrue(result["hasSmsPhone"]) def test_normalize_effective_preferences_treats_whitespace_phone_as_missing(self): current_user = { @@ -36,6 +37,7 @@ def test_normalize_effective_preferences_treats_whitespace_phone_as_missing(self self.assertTrue(result["emailNotifications"]) self.assertFalse(result["smsNotifications"]) self.assertFalse(result["hasPhone"]) + self.assertFalse(result["hasSmsPhone"]) def test_normalize_effective_preferences_rejects_text_when_explicitly_enabled_without_phone(self): current_user = { @@ -52,6 +54,37 @@ def test_normalize_effective_preferences_rejects_text_when_explicitly_enabled_wi self.assertEqual(ctx.exception.status_code, 400) self.assertEqual(ctx.exception.message, "SMS notifications require a valid phone number") + def test_normalize_effective_preferences_strips_text_when_phone_is_not_e164(self): + current_user = { + "notifPrefs": ["email", "text"], + "phone": "4125551234", + } + + result = normalize_effective_notification_state(current_user=current_user) + + self.assertEqual(result["notifPrefs"], ["email"]) + self.assertFalse(result["smsNotifications"]) + self.assertTrue(result["hasPhone"]) + self.assertFalse(result["hasSmsPhone"]) + + def test_normalize_effective_preferences_rejects_text_when_phone_is_naive_us_number(self): + current_user = { + "notifPrefs": [], + "phone": "4125551234", + } + + with self.assertRaises(APIError) as ctx: + normalize_effective_notification_state( + current_user=current_user, + sms_notifications_patch=True, + ) + + self.assertEqual(ctx.exception.status_code, 400) + self.assertEqual( + ctx.exception.message, + "SMS notifications require a phone number in E.164 format (for example +15551234567)", + ) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/unit/test_user_service.py b/backend/tests/unit/test_user_service.py index 6ebce74..54f7e90 100644 --- a/backend/tests/unit/test_user_service.py +++ b/backend/tests/unit/test_user_service.py @@ -27,6 +27,43 @@ def test_update_user_rejects_text_pref_when_effective_phone_missing(self): self.assertEqual(ctx.exception.status_code, 400) self.assertEqual(ctx.exception.message, "SMS notifications require a valid phone number") + def test_update_user_rejects_text_pref_when_phone_is_not_e164(self): + user_id = ObjectId() + + with patch( + "services.user_service.find_user", + return_value={"_id": user_id, "notifPrefs": [], "phone": "4125551234"}, + ), patch( + "services.user_service.build_user_patch", + return_value={"notifPrefs": ["text"]}, + ): + with self.assertRaises(APIError) as ctx: + update_user(user_id, payload={"notifPrefs": ["text"]}) + + self.assertEqual(ctx.exception.status_code, 400) + self.assertEqual( + ctx.exception.message, + "SMS notifications require a phone number in E.164 format (for example +15551234567)", + ) + + def test_update_user_auto_removes_text_pref_when_phone_patched_to_non_e164(self): + user_id = ObjectId() + current = {"_id": user_id, "notifPrefs": ["email", "text"], "phone": "+15550001111"} + persisted = {"_id": user_id, "notifPrefs": ["email"], "phone": "4125551234"} + + with patch("services.user_service.find_user", return_value=current), patch( + "services.user_service.build_user_patch", + return_value={"phone": "4125551234"}, + ), patch( + "services.user_service.update_user_with_mailbox_sync", + return_value=persisted, + ) as update_mock: + response = update_user(user_id, payload={"phone": "4125551234"}) + + self.assertEqual(response, persisted) + self.assertEqual(update_mock.call_args.kwargs["patch"]["phone"], "4125551234") + self.assertEqual(update_mock.call_args.kwargs["patch"]["notifPrefs"], ["email"]) + def test_update_user_auto_removes_text_pref_when_phone_cleared(self): user_id = ObjectId() current = {"_id": user_id, "notifPrefs": ["email", "text"], "phone": "+15550001111"} diff --git a/backend/tests/unit/test_validators.py b/backend/tests/unit/test_validators.py index 2eba040..c8fa99d 100644 --- a/backend/tests/unit/test_validators.py +++ b/backend/tests/unit/test_validators.py @@ -3,7 +3,7 @@ from bson import ObjectId from errors import APIError -from validators import normalize_email, parse_distinct_object_ids, parse_enum_set +from validators import is_e164_phone, normalize_email, parse_distinct_object_ids, parse_enum_set class ValidatorTests(unittest.TestCase): @@ -30,6 +30,18 @@ def test_parse_enum_set_rejects_invalid(self): with self.assertRaises(APIError): parse_enum_set(["email", "push"], field_name="notifPrefs", allowed={"email", "text"}) + def test_is_e164_phone_accepts_plus_and_digits(self): + self.assertTrue(is_e164_phone("+15550001111")) + self.assertTrue(is_e164_phone(" +441234567890 ")) + + def test_is_e164_phone_rejects_naive_or_invalid(self): + self.assertFalse(is_e164_phone("4125551234")) + self.assertFalse(is_e164_phone("+0123456789")) + self.assertFalse(is_e164_phone("+1")) + self.assertFalse(is_e164_phone("+1abc")) + self.assertFalse(is_e164_phone("")) + self.assertFalse(is_e164_phone("+1" + "0" * 15)) + if __name__ == "__main__": unittest.main() diff --git a/backend/validators.py b/backend/validators.py index 8ff9a21..3e7ae7b 100644 --- a/backend/validators.py +++ b/backend/validators.py @@ -72,6 +72,19 @@ def optional_phone(value: Any) -> str | None: return phone +def is_e164_phone(value: str) -> bool: + """True if ``value`` is a plausible E.164 destination (e.g. Twilio ``to``).""" + s = value.strip() + if len(s) < 2 or not s.startswith("+"): + return False + digits = s[1:] + if not digits.isdigit() or digits[0] == "0": + return False + if not 2 <= len(digits) <= 15: + return False + return True + + def parse_distinct_object_ids(values: Any, field_name: str) -> list[ObjectId]: if values is None: return [] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be7c572..12a802e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -80,6 +80,7 @@ function toSessionUser(session: ApiSessionMe): SessionUser { emailNotifications: session.emailNotifications, smsNotifications: session.smsNotifications, hasPhone: session.hasPhone, + hasSmsPhone: session.hasSmsPhone, }; } diff --git a/frontend/src/lib/api/contracts/types.ts b/frontend/src/lib/api/contracts/types.ts index 1a257d1..1a7637e 100644 --- a/frontend/src/lib/api/contracts/types.ts +++ b/frontend/src/lib/api/contracts/types.ts @@ -53,6 +53,8 @@ export interface ApiSessionMe { emailNotifications: boolean; smsNotifications: boolean; hasPhone: boolean; + /** True when stored phone is usable as an SMS destination (E.164). */ + hasSmsPhone: boolean; } export interface ApiMemberMailboxDay { @@ -79,6 +81,7 @@ export interface ApiMemberPreferences { emailNotifications: boolean; smsNotifications: boolean; hasPhone: boolean; + hasSmsPhone: boolean; } export type ApiMailRequestStatus = "ACTIVE" | "CANCELLED" | "RESOLVED"; diff --git a/frontend/src/lib/member-preferences.ts b/frontend/src/lib/member-preferences.ts index 8629c4a..885aa10 100644 --- a/frontend/src/lib/member-preferences.ts +++ b/frontend/src/lib/member-preferences.ts @@ -2,6 +2,8 @@ export interface NotificationPreferenceState { emailNotifications: boolean; smsNotifications: boolean; hasPhone: boolean; + /** Backend: phone is E.164 and can be used for SMS. */ + hasSmsPhone: boolean; } export interface NotificationSettingsState extends NotificationPreferenceState { @@ -11,12 +13,16 @@ export interface NotificationSettingsState extends NotificationPreferenceState { } export function deriveSettingsState(state: NotificationPreferenceState): NotificationSettingsState { - const smsDisabled = !state.hasPhone; + const smsDisabled = !state.hasSmsPhone; return { ...state, - smsNotifications: state.hasPhone ? state.smsNotifications : false, + smsNotifications: state.hasSmsPhone ? state.smsNotifications : false, smsDisabled, - smsInlineMessage: smsDisabled ? "Add a phone number to enable SMS notifications." : null, + smsInlineMessage: smsDisabled + ? state.hasPhone + ? "Use a phone number in international format (E.164, e.g. +1…) to enable SMS notifications." + : "Add a phone number to enable SMS notifications." + : null, }; } @@ -31,8 +37,8 @@ export function buildPreferencePatch( } if (typeof updates.smsNotifications === "boolean") { - patch.smsNotifications = current.hasPhone ? updates.smsNotifications : false; - } else if (!current.hasPhone && current.smsNotifications) { + patch.smsNotifications = current.hasSmsPhone ? updates.smsNotifications : false; + } else if (!current.hasSmsPhone && current.smsNotifications) { patch.smsNotifications = false; } diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index 5393276..20f46ad 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -9,6 +9,7 @@ export interface SessionUser { emailNotifications: boolean; smsNotifications: boolean; hasPhone: boolean; + hasSmsPhone: boolean; } export interface FeatureFlags { @@ -30,6 +31,7 @@ interface AppState { emailNotifications: boolean; smsNotifications: boolean; hasPhone: boolean; + hasSmsPhone: boolean; }) => void; logout: () => void; } diff --git a/frontend/src/pages/member/NotificationSettings.tsx b/frontend/src/pages/member/NotificationSettings.tsx index 2de57e9..b45a811 100644 --- a/frontend/src/pages/member/NotificationSettings.tsx +++ b/frontend/src/pages/member/NotificationSettings.tsx @@ -19,8 +19,14 @@ const NotificationSettings = () => { emailNotifications: sessionUser?.emailNotifications ?? false, smsNotifications: sessionUser?.smsNotifications ?? false, hasPhone: sessionUser?.hasPhone ?? false, + hasSmsPhone: sessionUser?.hasSmsPhone ?? false, }), - [sessionUser?.emailNotifications, sessionUser?.smsNotifications, sessionUser?.hasPhone], + [ + sessionUser?.emailNotifications, + sessionUser?.smsNotifications, + sessionUser?.hasPhone, + sessionUser?.hasSmsPhone, + ], ); const activePrefs = optimisticPrefs ?? basePrefs; const settings = deriveSettingsState(activePrefs); @@ -40,6 +46,7 @@ const NotificationSettings = () => { emailNotifications: updated.emailNotifications, smsNotifications: updated.smsNotifications, hasPhone: updated.hasPhone, + hasSmsPhone: updated.hasSmsPhone, }; setSessionNotificationPreferences(next); setOptimisticPrefs(next); @@ -61,7 +68,7 @@ const NotificationSettings = () => { useEffect(() => { if (pending) return; - if (!activePrefs.hasPhone && activePrefs.smsNotifications) { + if (!activePrefs.hasSmsPhone && activePrefs.smsNotifications) { const patch = buildPreferencePatch(basePrefs, { smsNotifications: false }); const optimisticNext = { ...basePrefs, @@ -69,7 +76,7 @@ const NotificationSettings = () => { }; void persistPreferences(patch, optimisticNext, basePrefs); } - }, [pending, activePrefs.hasPhone, activePrefs.smsNotifications, basePrefs, persistPreferences]); + }, [pending, activePrefs.hasSmsPhone, activePrefs.smsNotifications, basePrefs, persistPreferences]); if (!sessionUser) return null; diff --git a/frontend/src/test/app-auth.test.tsx b/frontend/src/test/app-auth.test.tsx index 6458b33..9a066ba 100644 --- a/frontend/src/test/app-auth.test.tsx +++ b/frontend/src/test/app-auth.test.tsx @@ -74,6 +74,7 @@ describe("App magic-link bootstrap", () => { emailNotifications: true, smsNotifications: false, hasPhone: false, + hasSmsPhone: false, }); render( diff --git a/frontend/src/test/member-preferences.test.ts b/frontend/src/test/member-preferences.test.ts index aff9b5b..a153d82 100644 --- a/frontend/src/test/member-preferences.test.ts +++ b/frontend/src/test/member-preferences.test.ts @@ -9,6 +9,7 @@ describe("member preferences helpers", () => { emailNotifications: true, smsNotifications: true, hasPhone: false, + hasSmsPhone: false, }, {}, ); @@ -22,6 +23,7 @@ describe("member preferences helpers", () => { emailNotifications: true, smsNotifications: false, hasPhone: true, + hasSmsPhone: true, }, { smsNotifications: true }, ); @@ -35,6 +37,7 @@ describe("member preferences helpers", () => { emailNotifications: false, smsNotifications: false, hasPhone: true, + hasSmsPhone: true, }, { emailNotifications: true }, ); @@ -47,10 +50,40 @@ describe("member preferences helpers", () => { emailNotifications: true, smsNotifications: true, hasPhone: false, + hasSmsPhone: false, }); expect(state.smsDisabled).toBe(true); expect(state.smsNotifications).toBe(false); expect(state.smsInlineMessage).toBe("Add a phone number to enable SMS notifications."); }); + + it("deriveSettingsState_disables_sms_when_phone_is_not_e164", () => { + const state = deriveSettingsState({ + emailNotifications: true, + smsNotifications: true, + hasPhone: true, + hasSmsPhone: false, + }); + + expect(state.smsDisabled).toBe(true); + expect(state.smsNotifications).toBe(false); + expect(state.smsInlineMessage).toBe( + "Use a phone number in international format (E.164, e.g. +1…) to enable SMS notifications.", + ); + }); + + it("buildPreferencePatch_clears_sms_when_phone_present_but_not_e164", () => { + const patch = buildPreferencePatch( + { + emailNotifications: true, + smsNotifications: true, + hasPhone: true, + hasSmsPhone: false, + }, + {}, + ); + + expect(patch).toEqual({ smsNotifications: false }); + }); });