1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::watchlist::{Watchlist, WatchlistItem};
4use crate::{Result, RhoodError};
5
6impl RobinhoodClient {
7 pub async fn get_watchlists(&self) -> Result<Vec<Watchlist>> {
14 self.get_paginated(
15 &self.api_url(paths::WATCHLISTS),
16 &[("owner_type", "custom")],
17 )
18 .await
19 }
20
21 pub async fn get_watchlist(&self, name_or_id: &str) -> Result<Watchlist> {
30 let lists = self.get_watchlists().await?;
31 lists
32 .iter()
33 .find(|list| {
34 list.display_name
35 .as_deref()
36 .is_some_and(|name| name.eq_ignore_ascii_case(name_or_id))
37 })
38 .or_else(|| {
39 lists
40 .iter()
41 .find(|list| list.id.as_deref() == Some(name_or_id))
42 })
43 .cloned()
44 .ok_or_else(|| {
45 RhoodError::InvalidParameter(format!("Watchlist not found: {name_or_id}"))
46 })
47 }
48
49 pub async fn get_watchlist_items(&self, name_or_id: &str) -> Result<Vec<WatchlistItem>> {
60 let list = self.get_watchlist(name_or_id).await?;
61
62 let is_options_only = list.allowed_object_types.as_ref().is_some_and(|types| {
63 types
64 .iter()
65 .all(|object_type| object_type == "option_strategy")
66 });
67 if is_options_only {
68 let name = list.display_name.as_deref().unwrap_or("this watchlist");
69 return Err(RhoodError::InvalidParameter(format!(
70 "'{name}' is an options watchlist and cannot be listed via the discovery API"
71 )));
72 }
73
74 let list_id = list
75 .id
76 .as_deref()
77 .ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
78 self.get_paginated(
79 &self.api_url(paths::WATCHLIST_ITEMS),
80 &[("list_id", list_id)],
81 )
82 .await
83 }
84
85 pub async fn add_to_watchlist(&self, name: &str, symbols: &[&str]) -> Result<()> {
95 self.require_writable()?;
96 let list = self.get_watchlist(name).await?;
97 let list_id = list
98 .id
99 .clone()
100 .ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
101
102 let mut object_ids = Vec::with_capacity(symbols.len());
103 for symbol in symbols {
104 let instrument = self
105 .cached_instrument(symbol)
106 .await?
107 .ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))?;
108 let instrument_id = instrument
109 .id
110 .clone()
111 .ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))?;
112 object_ids.push(instrument_id);
113 }
114 if object_ids.is_empty() {
115 return Ok(());
116 }
117 self.bulk_watchlist_edit(&list_id, &object_ids, "create")
118 .await?;
119 Ok(())
120 }
121
122 pub async fn remove_from_watchlist(&self, name: &str, symbols: &[&str]) -> Result<usize> {
138 self.require_writable()?;
139 let list = self.get_watchlist(name).await?;
140 let list_id = list
141 .id
142 .clone()
143 .ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
144 let items = self.get_watchlist_items(name).await?;
145
146 let object_ids: Vec<String> = symbols
147 .iter()
148 .filter_map(|symbol| {
149 items
150 .iter()
151 .find(|item| {
152 item.symbol
153 .as_deref()
154 .is_some_and(|item_symbol| item_symbol.eq_ignore_ascii_case(symbol))
155 })
156 .and_then(|item| item.object_id.clone())
157 })
158 .collect();
159
160 if object_ids.is_empty() {
161 return Ok(0);
162 }
163 self.bulk_watchlist_edit(&list_id, &object_ids, "delete")
164 .await?;
165 Ok(object_ids.len())
166 }
167
168 async fn bulk_watchlist_edit(
175 &self,
176 list_id: &str,
177 object_ids: &[String],
178 operation: &str,
179 ) -> Result<()> {
180 let payload = bulk_watchlist_payload(list_id, object_ids, operation);
181 let _: serde_json::Value = self
182 .post_json(&self.api_url(paths::WATCHLIST_ITEMS_WRITE), &payload)
183 .await?;
184 Ok(())
185 }
186}
187
188fn bulk_watchlist_payload(
194 list_id: &str,
195 object_ids: &[String],
196 operation: &str,
197) -> serde_json::Value {
198 let ops: Vec<serde_json::Value> = object_ids
199 .iter()
200 .map(|object_id| {
201 serde_json::json!({
202 "object_type": "instrument",
203 "object_id": object_id,
204 "operation": operation,
205 })
206 })
207 .collect();
208 serde_json::json!({ list_id: ops })
209}
210
211#[cfg(test)]
212mod endpoint_tests {
213 use crate::client::RobinhoodClient;
214 use crate::config::RhoodConfig;
215 use secrecy::SecretString;
216 use wiremock::matchers::{method, path, query_param};
217 use wiremock::{Mock, MockServer, ResponseTemplate};
218
219 async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
220 let dir = tempfile::tempdir().unwrap();
221 let mut config = RhoodConfig::default();
222 config.auth.token_cache_path = dir
223 .path()
224 .join("nonexistent-token.json")
225 .to_str()
226 .unwrap()
227 .to_string();
228 config.api.base_url = base_url.to_string();
229 let client = RobinhoodClient::with_config(config).unwrap();
230 client
231 .inject_test_auth(
232 SecretString::from("access-token"),
233 "Bearer".to_string(),
234 SecretString::from("refresh-token"),
235 )
236 .await;
237 (dir, client)
238 }
239
240 #[tokio::test]
241 async fn get_watchlists_follows_next_page_in_order() {
242 let server = MockServer::start().await;
243 let next_url = format!("{}/midlands/lists/?cursor=page-2", server.uri());
244 Mock::given(method("GET"))
245 .and(path("/midlands/lists/"))
246 .and(query_param("owner_type", "custom"))
247 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
248 "results": [{"id": "list-1", "display_name": "First"}],
249 "next": next_url,
250 "previous": null
251 })))
252 .mount(&server)
253 .await;
254 Mock::given(method("GET"))
255 .and(path("/midlands/lists/"))
256 .and(query_param("cursor", "page-2"))
257 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
258 "results": [{"id": "list-2", "display_name": "Second"}],
259 "next": null,
260 "previous": null
261 })))
262 .mount(&server)
263 .await;
264 let (_dir, client) = client_for_server(&server.uri()).await;
265
266 let lists = client.get_watchlists().await.unwrap();
267
268 let ids: Vec<_> = lists.iter().filter_map(|list| list.id.as_deref()).collect();
269 assert_eq!(ids, ["list-1", "list-2"]);
270 }
271
272 #[tokio::test]
273 async fn get_watchlist_finds_match_on_next_page() {
274 let server = MockServer::start().await;
275 let next_url = format!("{}/midlands/lists/?cursor=page-2", server.uri());
276 Mock::given(method("GET"))
277 .and(path("/midlands/lists/"))
278 .and(query_param("owner_type", "custom"))
279 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
280 "results": [{"id": "list-1", "display_name": "First"}],
281 "next": next_url,
282 "previous": null
283 })))
284 .mount(&server)
285 .await;
286 Mock::given(method("GET"))
287 .and(path("/midlands/lists/"))
288 .and(query_param("cursor", "page-2"))
289 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
290 "results": [{"id": "list-2", "display_name": "Later"}],
291 "next": null,
292 "previous": null
293 })))
294 .mount(&server)
295 .await;
296 let (_dir, client) = client_for_server(&server.uri()).await;
297
298 let list = client.get_watchlist("Later").await.unwrap();
299
300 assert_eq!(list.id.as_deref(), Some("list-2"));
301 }
302
303 #[tokio::test]
304 async fn get_watchlist_items_follows_next_page_in_order() {
305 let server = MockServer::start().await;
306 Mock::given(method("GET"))
307 .and(path("/midlands/lists/"))
308 .and(query_param("owner_type", "custom"))
309 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
310 "results": [{"id": "list-1", "display_name": "Tech"}],
311 "next": null,
312 "previous": null
313 })))
314 .mount(&server)
315 .await;
316 let next_url = format!("{}/discovery/lists/items/?cursor=page-2", server.uri());
317 Mock::given(method("GET"))
318 .and(path("/discovery/lists/items/"))
319 .and(query_param("list_id", "list-1"))
320 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
321 "results": [{"id": "item-1", "symbol": "AAPL"}],
322 "next": next_url,
323 "previous": null
324 })))
325 .mount(&server)
326 .await;
327 Mock::given(method("GET"))
328 .and(path("/discovery/lists/items/"))
329 .and(query_param("cursor", "page-2"))
330 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331 "results": [{"id": "item-2", "symbol": "MSFT"}],
332 "next": null,
333 "previous": null
334 })))
335 .mount(&server)
336 .await;
337 let (_dir, client) = client_for_server(&server.uri()).await;
338
339 let items = client.get_watchlist_items("Tech").await.unwrap();
340
341 let ids: Vec<_> = items.iter().filter_map(|item| item.id.as_deref()).collect();
342 assert_eq!(ids, ["item-1", "item-2"]);
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::bulk_watchlist_payload;
349 use crate::models::watchlist::{Watchlist, WatchlistItem};
350
351 #[test]
352 fn bulk_watchlist_payload_is_keyed_by_list_id() {
353 let list_id = "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d";
359 let object_ids = vec![
360 "ad059c69-0c1c-4c6b-8322-f53f1bbd69d4".to_string(),
361 "450dfc6d-5510-4d40-abfb-f633b7d9be3e".to_string(),
362 ];
363 let payload = bulk_watchlist_payload(list_id, &object_ids, "create");
364
365 let ops = payload[list_id].as_array().expect("keyed array of ops");
366 assert_eq!(ops.len(), 2);
367 assert_eq!(ops[0]["object_type"], "instrument");
368 assert_eq!(ops[0]["object_id"], "ad059c69-0c1c-4c6b-8322-f53f1bbd69d4");
369 assert_eq!(ops[0]["operation"], "create");
370 assert_eq!(ops[1]["object_id"], "450dfc6d-5510-4d40-abfb-f633b7d9be3e");
371 assert!(payload.get("items").is_none());
373 assert!(payload.get("list_id").is_none());
374 }
375
376 #[test]
377 fn bulk_watchlist_payload_supports_delete() {
378 let payload = bulk_watchlist_payload("L1", &["I1".to_string()], "delete");
379 assert_eq!(payload["L1"][0]["operation"], "delete");
380 }
381
382 #[test]
383 fn watchlist_deserializes_real_api_shape() {
384 let json = r#"{
385 "child_sort_direction": "ascending",
386 "child_sort_order": "custom",
387 "created_at": "2023-06-08T18:09:06.615545+00:00",
388 "display_description": null,
389 "display_name": "My First List",
390 "id": "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d",
391 "owner_type": "custom",
392 "parent_lists": [],
393 "read_permission": "private",
394 "updated_at": "2023-06-08T18:09:06.638995+00:00",
395 "allowed_object_types": ["currency_pair", "futures", "index", "instrument"],
396 "icon_emoji": "⚡",
397 "owner": "141fb69c-72c4-49c5-994b-1251039c8648",
398 "item_count": 16,
399 "child_info": {
400 "child_type": "item",
401 "children": []
402 },
403 "followed": true,
404 "default_expanded": true,
405 "related_lists": [],
406 "hero_images": null
407 }"#;
408 let list: Watchlist = serde_json::from_str(json).unwrap();
409 assert_eq!(list.display_name.as_deref(), Some("My First List"));
410 assert_eq!(
411 list.id.as_deref(),
412 Some("2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d")
413 );
414 assert_eq!(list.owner_type.as_deref(), Some("custom"));
415 assert_eq!(list.icon_emoji.as_deref(), Some("⚡"));
416 assert_eq!(list.item_count, Some(16));
417 assert_eq!(list.followed, Some(true));
418 assert_eq!(list.allowed_object_types.as_ref().unwrap().len(), 4);
419 let child_info = list.child_info.unwrap();
420 assert_eq!(child_info.child_type.as_deref(), Some("item"));
421 assert_eq!(child_info.children.unwrap().len(), 0);
422 }
423
424 #[test]
425 fn watchlist_handles_missing_fields() {
426 let json = r#"{"display_name": "Empty"}"#;
427 let list: Watchlist = serde_json::from_str(json).unwrap();
428 assert_eq!(list.display_name.as_deref(), Some("Empty"));
429 assert!(list.child_info.is_none());
430 assert!(list.id.is_none());
431 }
432
433 #[test]
434 fn watchlist_item_deserializes_real_api_shape() {
435 let json = r#"{
436 "created_at": "2023-06-08T18:09:06.618468Z",
437 "id": "57f6d7f4-0824-435b-9428-1f483bfc7c28",
438 "list_id": "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d",
439 "object_id": "e39ed23a-7bd1-4587-b060-71988d9ef483",
440 "object_type": "instrument",
441 "owner_type": "custom",
442 "updated_at": "2023-06-08T18:09:06.618479Z",
443 "weight": "1.00000",
444 "market_cap": 1287984697449.2463,
445 "high": 364.5,
446 "low": 339.9101,
447 "volume": 78838049.0,
448 "average_volume": 67016803.259264,
449 "high_52_weeks": 498.83,
450 "low_52_weeks": 217.8,
451 "pe_ratio": 322.345174,
452 "name": "Tesla",
453 "open_positions": 0,
454 "symbol": "TSLA",
455 "state": "active",
456 "price": 341.87,
457 "bid_price": 341.8,
458 "ask_price": 341.9,
459 "previous_close": 346.65,
460 "one_day_dollar_change": -4.78,
461 "one_day_percent_change": -1.3789124477138324,
462 "holdings": false
463 }"#;
464 let item: WatchlistItem = serde_json::from_str(json).unwrap();
465 assert_eq!(item.symbol.as_deref(), Some("TSLA"));
466 assert_eq!(item.name.as_deref(), Some("Tesla"));
467 assert_eq!(item.object_type.as_deref(), Some("instrument"));
468 assert_eq!(
469 item.object_id.as_deref(),
470 Some("e39ed23a-7bd1-4587-b060-71988d9ef483")
471 );
472 assert!((item.price.unwrap() - 341.87).abs() < 0.01);
473 assert!((item.one_day_percent_change.unwrap() - (-1.3789124477138324)).abs() < 0.001);
474 assert_eq!(item.holdings, Some(false));
475 assert_eq!(item.open_positions, Some(0));
476 }
477}