rovenue/funnel/
client.rs

1use std::sync::Arc;
2
3use crate::error::{RovenueError, RovenueResult};
4use crate::transport::http_client::HttpClient;
5use crate::transport::types::HttpPostRequest;
6
7use super::{ClaimInstallParams, FunnelClaimResult};
8
9pub struct FunnelClient {
10    http: Arc<HttpClient>,
11}
12
13impl FunnelClient {
14    pub fn new(http: Arc<HttpClient>) -> Self {
15        Self { http }
16    }
17
18    /// POST /v1/subscribers/claim-funnel-token. 200 → result; 404 → NotFound,
19    /// 410 → Expired, 409 → AlreadyClaimed.
20    pub fn claim_funnel_token(
21        &self,
22        token: &str,
23        anon_id: &str,
24    ) -> RovenueResult<FunnelClaimResult> {
25        let body = serde_json::json!({ "token": token, "anon_id": anon_id });
26        let (status, parsed) = self.http.post_json_status(
27            HttpPostRequest::new("/v1/subscribers/claim-funnel-token"),
28            &body,
29        )?;
30        match status {
31            200 => {
32                let data = parsed
33                    .and_then(|v| v.get("data").cloned())
34                    .ok_or(RovenueError::Internal())?;
35                let subscriber_id = data
36                    .get("subscriber_id")
37                    .and_then(|v| v.as_str())
38                    .ok_or(RovenueError::Internal())?
39                    .to_string();
40                let funnel_answers_json = data
41                    .get("funnel_answers")
42                    .cloned()
43                    .unwrap_or(serde_json::json!({}))
44                    .to_string();
45                Ok(FunnelClaimResult {
46                    subscriber_id,
47                    funnel_answers_json,
48                })
49            }
50            404 => Err(RovenueError::FunnelTokenNotFound()),
51            410 => Err(RovenueError::FunnelTokenExpired()),
52            409 => Err(RovenueError::FunnelTokenAlreadyClaimed()),
53            401 => Err(RovenueError::InvalidApiKey()),
54            _ => Err(RovenueError::ServerError()),
55        }
56    }
57
58    /// POST /v1/sdk/claim-install. 200 → recovered token; 404 → None (no match).
59    pub fn claim_install(
60        &self,
61        params: &ClaimInstallParams,
62        install_id: &str,
63    ) -> RovenueResult<Option<String>> {
64        let mut body = serde_json::json!({
65            "platform": params.platform,
66            "locale": params.locale,
67            "timezone": params.timezone,
68            "screen_dims": params.screen_dims,
69            "install_id": install_id,
70        });
71        if let Some(dm) = &params.device_model {
72            body["device_model"] = serde_json::json!(dm);
73        }
74        if let Some(ir) = &params.install_referrer {
75            body["install_referrer"] = serde_json::json!(ir);
76        }
77        let (status, parsed) = self
78            .http
79            .post_json_status(HttpPostRequest::new("/v1/sdk/claim-install"), &body)?;
80        match status {
81            200 => {
82                let token = parsed.and_then(|v| {
83                    v.get("data")
84                        .and_then(|d| d.get("token"))
85                        .and_then(|t| t.as_str())
86                        .map(str::to_string)
87                });
88                match token {
89                    Some(t) => Ok(Some(t)),
90                    None => Err(RovenueError::Internal()),
91                }
92            }
93            404 => Ok(None),
94            401 => Err(RovenueError::InvalidApiKey()),
95            _ => Err(RovenueError::ServerError()),
96        }
97    }
98
99    /// POST /v1/sdk/claim-via-email. Always 202; resolution happens later.
100    pub fn claim_via_email(&self, email: &str, install_id: &str) -> RovenueResult<()> {
101        let body = serde_json::json!({ "email": email, "install_id": install_id });
102        let (status, _) = self
103            .http
104            .post_json_status(HttpPostRequest::new("/v1/sdk/claim-via-email"), &body)?;
105        match status {
106            202 | 200 => Ok(()),
107            401 => Err(RovenueError::InvalidApiKey()),
108            _ => Err(RovenueError::ServerError()),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::error::ErrorKind;
117
118    fn client(url: &str) -> FunnelClient {
119        FunnelClient::new(Arc::new(
120            HttpClient::new(url.to_string(), "pk_test".into()).with_max_attempts(1),
121        ))
122    }
123
124    #[test]
125    fn claim_funnel_token_parses_200() {
126        let mut server = mockito::Server::new();
127        let m = server.mock("POST", "/v1/subscribers/claim-funnel-token")
128            .with_status(200).with_header("content-type", "application/json")
129            .with_body(r#"{"data":{"subscriber_id":"sub_1","entitlements":[],"funnel_answers":{"q1":"yes"}}}"#)
130            .create();
131        let r = client(&server.url())
132            .claim_funnel_token("tok", "rov_x")
133            .expect("ok");
134        assert_eq!(r.subscriber_id, "sub_1");
135        assert_eq!(r.funnel_answers_json, r#"{"q1":"yes"}"#);
136        m.assert();
137    }
138
139    #[test]
140    fn claim_funnel_token_maps_status_errors() {
141        type StatusCheck = fn(RovenueError) -> bool;
142        let cases: &[(u16, StatusCheck)] = &[
143            (404, |e: RovenueError| {
144                e.kind == ErrorKind::FunnelTokenNotFound
145            }),
146            (410, |e: RovenueError| {
147                e.kind == ErrorKind::FunnelTokenExpired
148            }),
149            (409, |e: RovenueError| {
150                e.kind == ErrorKind::FunnelTokenAlreadyClaimed
151            }),
152        ];
153        for (code, check_fn) in cases {
154            let mut server = mockito::Server::new();
155            let _m = server
156                .mock("POST", "/v1/subscribers/claim-funnel-token")
157                .with_status((*code).into())
158                .create();
159            let err = client(&server.url())
160                .claim_funnel_token("tok", "rov_x")
161                .unwrap_err();
162            assert!(check_fn(err), "status {code}");
163        }
164    }
165
166    #[test]
167    fn claim_install_returns_token_or_none() {
168        let mut server = mockito::Server::new();
169        let m_ok = server
170            .mock("POST", "/v1/sdk/claim-install")
171            .with_status(200)
172            .with_header("content-type", "application/json")
173            .with_body(r#"{"data":{"token":"recovered_tok"}}"#)
174            .create();
175        let params = ClaimInstallParams {
176            platform: "android".into(),
177            locale: "en-US".into(),
178            timezone: "UTC".into(),
179            screen_dims: "390x844".into(),
180            device_model: None,
181            install_referrer: Some("rovenue_funnel_token=recovered_tok".into()),
182        };
183        assert_eq!(
184            client(&server.url())
185                .claim_install(&params, "inst_1")
186                .unwrap(),
187            Some("recovered_tok".into())
188        );
189        m_ok.assert();
190
191        let mut server2 = mockito::Server::new();
192        let _m404 = server2
193            .mock("POST", "/v1/sdk/claim-install")
194            .with_status(404)
195            .create();
196        assert_eq!(
197            client(&server2.url())
198                .claim_install(&params, "inst_1")
199                .unwrap(),
200            None
201        );
202    }
203
204    #[test]
205    fn claim_install_malformed_200_returns_internal_error() {
206        let mut server = mockito::Server::new();
207        let _m = server
208            .mock("POST", "/v1/sdk/claim-install")
209            .with_status(200)
210            .with_header("content-type", "application/json")
211            .with_body(r#"{"data":{}}"#)
212            .create();
213        let params = ClaimInstallParams {
214            platform: "ios".into(),
215            locale: "en-US".into(),
216            timezone: "UTC".into(),
217            screen_dims: "390x844".into(),
218            device_model: None,
219            install_referrer: None,
220        };
221        let err = client(&server.url())
222            .claim_install(&params, "inst_m")
223            .unwrap_err();
224        assert!(
225            err.kind == ErrorKind::Internal,
226            "malformed 200 must be Internal error"
227        );
228    }
229
230    #[test]
231    fn claim_via_email_accepts_202() {
232        let mut server = mockito::Server::new();
233        let m = server
234            .mock("POST", "/v1/sdk/claim-via-email")
235            .with_status(202)
236            .create();
237        client(&server.url())
238            .claim_via_email("a@b.com", "inst_1")
239            .expect("202 ok");
240        m.assert();
241    }
242}