Coverage Report

Created: 2026-07-21 00:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}