super-stt-shared/src/daemon/widget_subscription/tests.rs
Line | Count | Source |
1 | | use super::*; |
2 | | |
3 | | // Backoff progression is now `RetryStrategy` (see `daemon::retry` tests); the |
4 | | // subscription loop just drives it via `next_delay`/`should_retry`/`reset`. |
5 | | |
6 | | #[test] |
7 | 2 | fn http_error_is_invalid_session_matches_only_invalid_session() { |
8 | | use http_client::HttpError; |
9 | | // The replacement for the old free `is_invalid_session(&str)` |
10 | | // is `HttpError::is_invalid_session()` on the typed error. |
11 | 2 | assert!( |
12 | 2 | HttpError::InvalidSession { |
13 | 2 | reason: "unknown".into() |
14 | 2 | } |
15 | 2 | .is_invalid_session() |
16 | | ); |
17 | 2 | assert!( |
18 | 2 | HttpError::InvalidSession { |
19 | 2 | reason: "expired".into() |
20 | 2 | } |
21 | 2 | .is_invalid_session() |
22 | | ); |
23 | | // Other variants must NOT classify as invalid_session. |
24 | 2 | assert!( |
25 | 2 | !HttpError::AuthDenied { |
26 | 2 | reason: "user_denied".into() |
27 | 2 | } |
28 | 2 | .is_invalid_session() |
29 | | ); |
30 | 2 | assert!(!HttpError::Other("connection refused".into()).is_invalid_session()); |
31 | 2 | } |
32 | | |
33 | | #[test] |
34 | 2 | fn is_user_denied_reason_matches_both_deny_variants() { |
35 | 2 | assert!(is_user_denied_reason("user_denied")); |
36 | 2 | assert!(is_user_denied_reason("user_denied_cached")); |
37 | | // Other reasons must not. |
38 | 2 | assert!(!is_user_denied_reason("user_dismissed")); |
39 | 2 | assert!(!is_user_denied_reason("popup_failed")); |
40 | 2 | } |
41 | | |
42 | | #[test] |
43 | 2 | fn is_user_denied_matches_both_reasons() { |
44 | | use crate::daemon::http_client::HttpError; |
45 | 2 | let denied = |reason: &str| HttpError::AuthDenied { |
46 | 8 | reason: reason.to_string(), |
47 | 8 | }; |
48 | | // Both fresh denials and deny-cache hits must terminate the |
49 | | // subscription so we don't spam the daemon. |
50 | 2 | assert!(is_user_denied(&denied("user_denied"))); |
51 | 2 | assert!(is_user_denied(&denied("user_denied_cached"))); |
52 | | // A dismissed popup is recoverable — the user just walked away, |
53 | | // so the next attempt will pop a fresh prompt. Do NOT treat as |
54 | | // blocked. |
55 | 2 | assert!(!is_user_denied(&denied("user_dismissed"))); |
56 | | // popup_failed / invalid_scope / etc. — those are infra |
57 | | // problems, the consumer's normal backoff path handles them. |
58 | 2 | assert!(!is_user_denied(&denied("popup_failed"))); |
59 | 2 | assert!(!is_user_denied(&HttpError::InvalidSession { |
60 | 2 | reason: "expired".to_string(), |
61 | 2 | })); |
62 | 2 | } |
63 | | |
64 | | /// The classifier keys off the typed [`HttpError::AuthDenied`] reason, so only |
65 | | /// user-initiated denials drive the Blocked path; every other `auth_denied` |
66 | | /// reason takes the ordinary backoff/reconnect path. |
67 | | #[test] |
68 | 2 | fn user_denied_reasons_classify_as_blocked() { |
69 | | use crate::daemon::http_client::HttpError; |
70 | 2 | let denied = |reason: &str| HttpError::AuthDenied { |
71 | 12 | reason: reason.to_string(), |
72 | 12 | }; |
73 | 4 | for reason in ["user_denied", "user_denied_cached"]2 { |
74 | 4 | assert!( |
75 | 4 | is_user_denied(&denied(reason)), |
76 | | "`{reason}` must classify as user-denied" |
77 | | ); |
78 | | } |
79 | 8 | for reason in [ |
80 | 2 | "user_dismissed", |
81 | 2 | "popup_failed", |
82 | 2 | "throttled", |
83 | 2 | "invalid_scope", |
84 | 2 | ] { |
85 | 8 | assert!( |
86 | 8 | !is_user_denied(&denied(reason)), |
87 | | "`{reason}` must NOT classify as user-denied" |
88 | | ); |
89 | | } |
90 | 2 | } |
91 | | |
92 | | /// Document the Blocked variant carries the daemon's reason |
93 | | /// string verbatim so consumers (the applet popup, the settings |
94 | | /// connection page) can surface it to the user without having |
95 | | /// to parse anything out of it. |
96 | | #[test] |
97 | 2 | fn blocked_update_carries_reason_string() { |
98 | 2 | let update = WidgetSubscriptionUpdate::Blocked { |
99 | 2 | reason: "auth_denied (user_denied_cached)".to_string(), |
100 | 2 | }; |
101 | 2 | match update { |
102 | 2 | WidgetSubscriptionUpdate::Blocked { reason } => { |
103 | 2 | assert_eq!(reason, "auth_denied (user_denied_cached)"); |
104 | | } |
105 | 0 | other => panic!("variant constructed Blocked but pattern saw {other:?}"), |
106 | | } |
107 | 2 | } |
108 | | |
109 | | /// Routing contract: which `HttpError` variant maps to which |
110 | | /// `WidgetSubscriptionUpdate` variant. Pins the fix for the |
111 | | /// "daemon offline reported as 'session revoked'" UX bug — |
112 | | /// `HttpError::Other` must drive `Disconnected`, not |
113 | | /// `NeedsReauth`. The actual routing lives inside the |
114 | | /// `run_widget_subscription` match, but we can verify the |
115 | | /// intent by enumerating the discriminants we expect each |
116 | | /// variant to land in. |
117 | | #[test] |
118 | 2 | fn obtain_error_routing_contract() { |
119 | | use http_client::HttpError; |
120 | | |
121 | | // The catch-all `Err(e @ HttpError::Other(_)) => Disconnected` |
122 | | // arm fires here. Failing this would mean a daemon-offline |
123 | | // state is again reported as a revoked session. |
124 | 2 | let daemon_offline = HttpError::Other("Daemon HTTP listener not running.".to_string()); |
125 | 2 | assert!( |
126 | 2 | !daemon_offline.is_invalid_session(), |
127 | | "Other must not be classified as invalid_session" |
128 | | ); |
129 | 2 | assert!(matches!0 (daemon_offline, HttpError::Other(_))); |
130 | | |
131 | | // `InvalidSession` and non-user-denied `AuthDenied` must |
132 | | // land in `NeedsReauth`. We can't observe the stream output |
133 | | // synchronously here, but we lock in the predicate the |
134 | | // routing relies on. |
135 | 2 | let invalid = HttpError::InvalidSession { |
136 | 2 | reason: "expired".to_string(), |
137 | 2 | }; |
138 | 2 | assert!(invalid.is_invalid_session()); |
139 | | |
140 | 2 | let popup_failed = HttpError::AuthDenied { |
141 | 2 | reason: "popup_failed".to_string(), |
142 | 2 | }; |
143 | 2 | assert!( |
144 | 2 | !matches!0 (&popup_failed, HttpError::AuthDenied { reason } if is_user_denied_reason(reason)0 ), |
145 | | "popup_failed must NOT be classified as user-denied — \ |
146 | | it's recoverable, not terminal" |
147 | | ); |
148 | | |
149 | | // The terminal `Blocked` path only triggers on these two. |
150 | 4 | for reason in ["user_denied", "user_denied_cached"]2 { |
151 | 4 | let e = HttpError::AuthDenied { |
152 | 4 | reason: reason.to_string(), |
153 | 4 | }; |
154 | 4 | assert!( |
155 | 4 | matches!(&e, HttpError::AuthDenied { reason } if is_user_denied_reason(reason)), |
156 | | "`{reason}` must classify as terminal user-denied" |
157 | | ); |
158 | | } |
159 | 2 | } |
160 | | |
161 | | #[test] |
162 | 2 | fn config_defaults_are_sane() { |
163 | 2 | let cfg = WidgetSubscriptionConfig::new( |
164 | 2 | AppId("test-app"), |
165 | | "Test App", |
166 | 2 | &["recording_events"], |
167 | 2 | &["recording_state"], |
168 | | ); |
169 | | // Idle timeout must be ≥ 2× the daemon's keepalive interval (30 s). |
170 | 2 | assert!(cfg.idle_timeout >= Duration::from_secs(60)); |
171 | | // Backoff must grow. |
172 | 2 | assert!(cfg.initial_backoff < cfg.max_backoff); |
173 | 2 | } |