git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/raindrop/api.rs (24.9K)

  1 //! The Raindrop.io REST client — phase 2. It fetches and it sends; deciding
  2 //! what to send is the merge's job (`super::plan`), and nothing here has an
  3 //! opinion about it.
  4 //!
  5 //! Checked against developer.raindrop.io on 2026-10-02: REST under
  6 //! `api.raindrop.io/rest/v1`, `Authorization: Bearer <token>` (a personal
  7 //! *test token* from the integration settings, which does not expire), 120
  8 //! requests a minute with `429` past that, timestamps in ISO 8601, and:
  9 //!
 10 //! * `GET /raindrops/{collection}` — `-1` is Unsorted — 50 a page at most;
 11 //! * `POST /raindrop` creates, `PUT /raindrop/{id}` is a **partial** update
 12 //!   (only the fields sent change), `DELETE /raindrop/{id}` moves to Trash —
 13 //!   and deletes **permanently** when the item is already in Trash, which is
 14 //!   why only ids just fetched from a live collection are ever trashed.
 15 //!
 16 //! The token is held as an `accounts::Secret` and only ever goes into the
 17 //! `Authorization` header; reqwest's errors carry the URL, never headers.
 18 
 19 use super::{Local, Plan, RaindropId, Remote, Synced};
 20 use crate::accounts::Secret;
 21 
 22 const BASE: &str = "https://api.raindrop.io/rest/v1";
 23 /// The Unsorted collection: the one this browser mirrors (RAINDROP-SYNC.md).
 24 pub const UNSORTED: i64 = -1;
 25 const PER_PAGE: usize = 50;
 26 /// A fetch that runs past this many pages is not a bookmark collection.
 27 const MAX_PAGES: usize = 400;
 28 /// The orders a collection is read in until every bookmark has been seen —
 29 /// different sorts break ties differently, so their union fills the gaps.
 30 const SORTS: [&str; 6] = ["created", "-created", "title", "-title", "domain", "-domain"];
 31 
 32 #[derive(Debug, Clone, PartialEq)]
 33 pub enum ApiError {
 34     /// 401/403: the token is wrong, revoked, or for nothing.
 35     Unauthorized,
 36     /// Still 429 after waiting once.
 37     RateLimited,
 38     /// Every read left bookmarks unseen (or the collection changed between
 39     /// reads), so the list may be missing one — and a missing bookmark reads
 40     /// as a deletion. Nothing is planned from it.
 41     Incomplete { distinct: usize, count: usize },
 42     Http(u16, String),
 43     Network(String),
 44     Parse(String),
 45 }
 46 
 47 impl std::fmt::Display for ApiError {
 48     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 49         match self {
 50             ApiError::Unauthorized => write!(f, "Raindrop refused the token"),
 51             ApiError::RateLimited => write!(f, "Raindrop's rate limit; try again in a minute"),
 52             ApiError::Incomplete { distinct, count } => write!(
 53                 f,
 54                 "could not read the whole collection ({distinct} of {count} bookmarks seen); \
 55                  nothing was changed, trying again next pass"
 56             ),
 57             ApiError::Http(code, body) => write!(f, "Raindrop answered {code}: {body}"),
 58             ApiError::Network(e) => write!(f, "could not reach Raindrop: {e}"),
 59             ApiError::Parse(e) => write!(f, "unexpected answer from Raindrop: {e}"),
 60         }
 61     }
 62 }
 63 
 64 pub struct Client {
 65     http: reqwest::blocking::Client,
 66     token: Secret,
 67     base: String,
 68 }
 69 
 70 impl Client {
 71     /// The real Raindrop — or, when `CCE_RAINDROP_API` is set, a stand-in at
 72     /// that base URL, which is how the whole browser is tested end to end
 73     /// without writing to an account.
 74     pub fn new(token: Secret) -> Self {
 75         match std::env::var("CCE_RAINDROP_API") {
 76             Ok(base) if !base.is_empty() => {
 77                 log::warn!("raindrop: using the stand-in API at {base}");
 78                 Self::with_base(token, &base)
 79             }
 80             _ => Self::with_base(token, BASE),
 81         }
 82     }
 83 
 84     /// Against another server — the tests' stand-in.
 85     pub fn with_base(token: Secret, base: &str) -> Self {
 86         let http = reqwest::blocking::Client::builder()
 87             .user_agent(concat!("cce-browser/", env!("CARGO_PKG_VERSION")))
 88             .timeout(std::time::Duration::from_secs(30))
 89             .build()
 90             .expect("an HTTP client with default TLS");
 91         Self { http, token, base: base.trim_end_matches('/').to_string() }
 92     }
 93 
 94     /// Send one request, waiting out a single `429` (Raindrop says when its
 95     /// window resets; never longer than a minute), and return the JSON body.
 96     fn call(
 97         &self,
 98         method: reqwest::Method,
 99         path: &str,
100         body: Option<&serde_json::Value>,
101     ) -> Result<serde_json::Value, ApiError> {
102         for attempt in 0..2 {
103             let mut req = self
104                 .http
105                 .request(method.clone(), format!("{}{path}", self.base))
106                 .bearer_auth(self.token.expose());
107             if let Some(b) = body {
108                 // By hand rather than reqwest's `json` feature, to build the
109                 // same reqwest the sibling crates already compile.
110                 req = req
111                     .header(reqwest::header::CONTENT_TYPE, "application/json")
112                     .body(b.to_string());
113             }
114             let resp = req.send().map_err(|e| ApiError::Network(e.without_url().to_string()))?;
115             let status = resp.status().as_u16();
116             if status == 429 && attempt == 0 {
117                 let now = super::unix_now();
118                 let reset = resp
119                     .headers()
120                     .get("x-ratelimit-reset")
121                     .and_then(|v| v.to_str().ok())
122                     .and_then(|v| v.parse::<u64>().ok())
123                     .unwrap_or(now + 60);
124                 std::thread::sleep(std::time::Duration::from_secs(reset.saturating_sub(now).clamp(1, 60)));
125                 continue;
126             }
127             let text = resp.text().map_err(|e| ApiError::Network(e.without_url().to_string()))?;
128             return match status {
129                 200..=299 => serde_json::from_str(&text).map_err(|e| ApiError::Parse(e.to_string())),
130                 401 | 403 => Err(ApiError::Unauthorized),
131                 429 => Err(ApiError::RateLimited),
132                 _ => Err(ApiError::Http(status, text.chars().take(200).collect())),
133             };
134         }
135         Err(ApiError::RateLimited)
136     }
137 
138     /// Every bookmark in `collection`, each exactly once.
139     ///
140     /// Raindrop pages by position within a sort, and **ties do not keep their
141     /// order from one page request to the next**: bookmarks saved in one batch
142     /// share a creation time to the millisecond, and a fetch of a real
143     /// 178-bookmark collection returned 7 of them twice and 7 others never,
144     /// with the row total still matching `count`. A bookmark missing from a
145     /// fetch reads as "deleted in Raindrop" to the merge, so a short list is
146     /// never handed on: rows are kept by id, the number of *distinct* ids must
147     /// equal `count`, and until it does the collection is read again under
148     /// another sort — each orders the ties differently — and the reads
149     /// combined. A deletion between reads makes the union overshoot `count`,
150     /// and that is refused too.
151     pub fn fetch(&self, collection: i64) -> Result<Vec<Remote>, ApiError> {
152         let mut seen: Vec<Remote> = Vec::new();
153         let mut ids = std::collections::HashSet::new();
154         let mut reported = 0;
155         for sort in SORTS {
156             let (rows, count) = self.fetch_once(collection, sort)?;
157             reported = count;
158             for r in rows {
159                 if ids.insert(r.id) {
160                     seen.push(r);
161                 }
162             }
163             if seen.len() == count {
164                 return Ok(seen);
165             }
166             if seen.len() > count {
167                 break;
168             }
169         }
170         Err(ApiError::Incomplete { distinct: seen.len(), count: reported })
171     }
172 
173     /// One read of every page under one sort: the rows, and Raindrop's count.
174     fn fetch_once(&self, collection: i64, sort: &str) -> Result<(Vec<Remote>, usize), ApiError> {
175         let mut out = Vec::new();
176         let mut count = None;
177         for page in 0..MAX_PAGES {
178             let v = self.call(
179                 reqwest::Method::GET,
180                 &format!("/raindrops/{collection}?perpage={PER_PAGE}&page={page}&sort={sort}"),
181                 None,
182             )?;
183             count = v["count"].as_u64().map(|c| c as usize).or(count);
184             let items = v["items"].as_array().ok_or_else(|| ApiError::Parse("no items".into()))?;
185             for item in items {
186                 out.push(parse_item(item)?);
187             }
188             if items.len() < PER_PAGE {
189                 break;
190             }
191         }
192         // Without a count there is nothing to check a read against, and an
193         // unchecked read is exactly what this exists to prevent.
194         let count = count.ok_or_else(|| ApiError::Parse("no count in the answer".into()))?;
195         Ok((out, count))
196     }
197 
198     /// Create a bookmark; returns its new id. Link and title only — Raindrop
199     /// fills in the rest itself.
200     pub fn create(&self, collection: i64, link: &str, title: &str) -> Result<RaindropId, ApiError> {
201         let body = serde_json::json!({
202             "link": link,
203             "title": title,
204             "collection": { "$id": collection },
205         });
206         let v = self.call(reqwest::Method::POST, "/raindrop", Some(&body))?;
207         v["item"]["_id"].as_u64().ok_or_else(|| ApiError::Parse("created item has no _id".into()))
208     }
209 
210     /// Change a title. A partial update: nothing else on the item is touched.
211     pub fn rename(&self, id: RaindropId, title: &str) -> Result<(), ApiError> {
212         let body = serde_json::json!({ "title": title });
213         self.call(reqwest::Method::PUT, &format!("/raindrop/{id}"), Some(&body)).map(|_| ())
214     }
215 
216     /// Move to Trash. Only ever called with ids fetched from the live
217     /// collection this pass — on an item already in Trash this is permanent.
218     pub fn trash(&self, id: RaindropId) -> Result<(), ApiError> {
219         self.call(reqwest::Method::DELETE, &format!("/raindrop/{id}"), None).map(|_| ())
220     }
221 }
222 
223 fn parse_item(v: &serde_json::Value) -> Result<Remote, ApiError> {
224     let id = v["_id"].as_u64().ok_or_else(|| ApiError::Parse("an item has no _id".into()))?;
225     let link = v["link"].as_str().ok_or_else(|| ApiError::Parse(format!("item {id} has no link")))?;
226     // Tabs and line breaks become spaces here, at the source: the local
227     // store has no escaping and flattens them, and a title that differed
228     // only in that way would read as renamed in Raindrop on every pass.
229     let flat = |s: &str| s.replace(['\t', '\n', '\r'], " ");
230     Ok(Remote {
231         id,
232         link: flat(link),
233         title: flat(v["title"].as_str().unwrap_or_default()),
234         created: v["created"].as_str().and_then(iso8601_secs).unwrap_or(0),
235     })
236 }
237 
238 /// `2026-10-02T16:04:05.123Z` → seconds since the epoch. Just the shape
239 /// Raindrop sends (UTC, `Z`, optional fraction); anything else is `None`.
240 pub fn iso8601_secs(s: &str) -> Option<u64> {
241     let b = s.as_bytes();
242     if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
243         return None;
244     }
245     if !s.ends_with('Z') {
246         return None;
247     }
248     let num = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
249     let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
250     let (h, mi, se) = (num(11..13)?, num(14..16)?, num(17..19)?);
251     if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 60 {
252         return None;
253     }
254     // Days from the civil date (Howard Hinnant's algorithm).
255     let y2 = if mo <= 2 { y - 1 } else { y };
256     let era = y2.div_euclid(400);
257     let yoe = y2 - era * 400;
258     let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
259     let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
260     let days = era * 146_097 + doe - 719_468;
261     u64::try_from(days * 86_400 + h * 3600 + mi * 60 + se).ok()
262 }
263 
264 /// Seconds since the epoch → `2026-10-02T16:04:05Z`; the inverse of
265 /// [`iso8601_secs`], for the sync log.
266 pub fn iso8601(secs: u64) -> String {
267     let days = (secs / 86_400) as i64;
268     let rem = secs % 86_400;
269     // Civil date from days (Howard Hinnant's algorithm).
270     let z = days + 719_468;
271     let era = z.div_euclid(146_097);
272     let doe = z - era * 146_097;
273     let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
274     let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
275     let mp = (5 * doy + 2) / 153;
276     let d = doy - (153 * mp + 2) / 5 + 1;
277     let m = if mp < 10 { mp + 3 } else { mp - 9 };
278     let y = yoe + era * 400 + i64::from(m <= 2);
279     format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
280 }
281 
282 /// What applying a plan's remote half did — the input to `Plan::base_after`,
283 /// which keeps the base honest about anything that failed.
284 #[derive(Debug, Default, Clone, PartialEq)]
285 pub struct Applied {
286     /// `(local URL, new id)` for each create Raindrop accepted.
287     pub created: Vec<(String, RaindropId)>,
288     pub failed_renames: Vec<RaindropId>,
289     pub failed_trash: Vec<RaindropId>,
290     /// One line per failure, for the status.
291     pub errors: Vec<String>,
292 }
293 
294 /// Apply a plan's remote half. A failure on one item does not stop the rest;
295 /// it is recorded, and the base built from the result leaves that item to be
296 /// retried. Unauthorized stops everything — every other call would fail too.
297 pub fn apply_remote(client: &Client, collection: i64, plan: &Plan) -> Result<Applied, ApiError> {
298     let mut out = Applied::default();
299     let mut note = |what: String, e: ApiError| -> Result<(), ApiError> {
300         if e == ApiError::Unauthorized {
301             return Err(e);
302         }
303         out.errors.push(format!("{what}: {e}"));
304         Ok(())
305     };
306     let mut created = Vec::new();
307     let mut failed_renames = Vec::new();
308     let mut failed_trash = Vec::new();
309     for l in &plan.create_remote {
310         match client.create(collection, &l.url, &l.title) {
311             Ok(id) => created.push((l.url.clone(), id)),
312             Err(e) => note(format!("create {}", l.url), e)?,
313         }
314     }
315     for (id, title) in &plan.rename_remote {
316         if let Err(e) = client.rename(*id, title) {
317             failed_renames.push(*id);
318             note(format!("rename {id}"), e)?;
319         }
320     }
321     for id in &plan.trash_remote {
322         if let Err(e) = client.trash(*id) {
323             failed_trash.push(*id);
324             note(format!("trash {id}"), e)?;
325         }
326     }
327     out.created = created;
328     out.failed_renames = failed_renames;
329     out.failed_trash = failed_trash;
330     Ok(out)
331 }
332 
333 /// The plan, readably — what the dry run prints.
334 pub fn describe(plan: &Plan, local: &[Local], remote: &[Remote], base: &[Synced]) -> String {
335     let mut s = format!(
336         "here {} · Raindrop {} · synced before {}\n",
337         local.len(),
338         remote.len(),
339         base.len()
340     );
341     let title_of = |id: &RaindropId| {
342         remote.iter().find(|r| r.id == *id).map(|r| r.link.as_str()).unwrap_or("?")
343     };
344     let mut line = |label: &str, items: Vec<String>| {
345         if !items.is_empty() {
346             s.push_str(&format!("\n{label} ({}):\n", items.len()));
347             for i in items {
348                 s.push_str(&format!("  {i}\n"));
349             }
350         }
351     };
352     line("create in Raindrop", plan.create_remote.iter().map(|l| format!("{}  {}", l.url, l.title)).collect());
353     line("rename in Raindrop", plan.rename_remote.iter().map(|(id, t)| format!("{} → {t}", title_of(id))).collect());
354     line("move to Raindrop's trash", plan.trash_remote.iter().map(|id| title_of(id).to_string()).collect());
355     line("relink here", plan.relink_local.iter().map(|(a, b)| format!("{a} → {b}")).collect());
356     line("rename here", plan.rename_local.iter().map(|(u, t)| format!("{u} → {t}")).collect());
357     line("delete here", plan.delete_local.clone());
358     line("add here", plan.add_local.iter().map(|l| format!("{}  {}", l.url, l.title)).collect());
359     if plan.is_noop() {
360         s.push_str("\nnothing to do — in sync\n");
361     }
362     s
363 }
364 
365 #[cfg(test)]
366 pub(super) mod tests {
367     use super::*;
368     use std::io::{BufRead, BufReader, Read, Write};
369     use std::sync::{Arc, Mutex};
370 
371     /// A stand-in Raindrop: answers each request with the next scripted
372     /// `(status, extra headers, body)` and records `(request line, body)`.
373     pub(in crate::raindrop) fn server(script: Vec<(u16, &'static str, String)>) -> (String, Arc<Mutex<Vec<(String, String)>>>) {
374         let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
375         let base = format!("http://{}", listener.local_addr().unwrap());
376         let seen = Arc::new(Mutex::new(Vec::new()));
377         let log = seen.clone();
378         std::thread::spawn(move || {
379             for (status, headers, body) in script {
380                 let Ok((stream, _)) = listener.accept() else { return };
381                 let mut reader = BufReader::new(stream);
382                 let mut first = String::new();
383                 reader.read_line(&mut first).unwrap();
384                 let mut len = 0;
385                 let mut auth = String::new();
386                 loop {
387                     let mut h = String::new();
388                     reader.read_line(&mut h).unwrap();
389                     if h == "\r\n" || h.is_empty() {
390                         break;
391                     }
392                     let lower = h.to_lowercase();
393                     if let Some(v) = lower.strip_prefix("content-length:") {
394                         len = v.trim().parse().unwrap();
395                     }
396                     if lower.starts_with("authorization:") {
397                         auth = h.trim().to_string();
398                     }
399                 }
400                 let mut req_body = vec![0; len];
401                 reader.read_exact(&mut req_body).unwrap();
402                 log.lock().unwrap().push((
403                     format!("{} | {auth}", first.trim()),
404                     String::from_utf8_lossy(&req_body).into_owned(),
405                 ));
406                 let mut stream = reader.into_inner();
407                 let _ = write!(
408                     stream,
409                     "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{headers}\
410                      Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
411                     body.len()
412                 );
413             }
414         });
415         (base, seen)
416     }
417 
418     pub(in crate::raindrop) fn items(range: std::ops::Range<u64>, count: usize) -> String {
419         let items: Vec<_> = range
420             .map(|i| serde_json::json!({
421                 "_id": i, "link": format!("https://{i}.test/"), "title": format!("t{i}"),
422                 "created": "2026-10-02T16:04:05.123Z", "collection": {"$id": -1},
423             }))
424             .collect();
425         serde_json::json!({ "result": true, "items": items, "count": count }).to_string()
426     }
427 
428     fn client(base: &str) -> Client {
429         Client::with_base(Secret::from("tok-123".to_string()), base)
430     }
431 
432     #[test]
433     fn fetch_pages_until_a_short_page_and_checks_the_count() {
434         let (base, seen) = server(vec![(200, "", items(1..51, 53)), (200, "", items(51..54, 53))]);
435         let all = client(&base).fetch(UNSORTED).unwrap();
436         assert_eq!(all.len(), 53);
437         assert_eq!(all[0], Remote { id: 1, link: "https://1.test/".into(), title: "t1".into(), created: 1_790_957_045 });
438         let seen = seen.lock().unwrap();
439         assert!(seen[0].0.starts_with("GET /raindrops/-1?perpage=50&page=0&sort=created"));
440         assert!(seen[1].0.contains("page=1"));
441         assert!(seen[0].0.ends_with("authorization: Bearer tok-123") || seen[0].0.ends_with("Authorization: Bearer tok-123"));
442     }
443 
444     /// `items` with chosen ids, for pages that repeat or drop some.
445     fn rows(ids: &[u64], count: usize) -> String {
446         let items: Vec<_> = ids
447             .iter()
448             .map(|i| serde_json::json!({
449                 "_id": i, "link": format!("https://{i}.test/"), "title": format!("t{i}"),
450                 "created": "2026-03-19T16:58:01.830Z",
451             }))
452             .collect();
453         serde_json::json!({ "result": true, "items": items, "count": count }).to_string()
454     }
455 
456     #[test]
457     fn tied_rows_that_repeat_and_vanish_are_read_again() {
458         // What Raindrop did: 52 bookmarks, page 1 repeats 50 and never shows 51.
459         let first: Vec<u64> = (1..=50).collect();
460         let (base, seen) = server(vec![
461             (200, "", rows(&first, 52)),
462             (200, "", rows(&[50, 52], 52)),
463             // The second read, newest first, has the tie the other way round.
464             (200, "", rows(&(3..=52).rev().collect::<Vec<_>>(), 52)),
465             (200, "", rows(&[2, 1], 52)),
466         ]);
467         let all = client(&base).fetch(UNSORTED).unwrap();
468         let mut ids: Vec<_> = all.iter().map(|r| r.id).collect();
469         ids.sort();
470         assert_eq!(ids, (1..=52).collect::<Vec<_>>(), "every bookmark, each once");
471         let seen = seen.lock().unwrap();
472         assert!(seen[2].0.contains("sort=-created"), "read again under another sort");
473     }
474 
475     #[test]
476     fn a_collection_never_read_whole_is_refused() {
477         // Every read misses id 51.
478         let script: Vec<_> = (0..6)
479             .flat_map(|_| [(200, "", rows(&(1..=50).collect::<Vec<_>>(), 52)), (200, "", rows(&[50, 52], 52))])
480             .collect();
481         let (base, _) = server(script);
482         assert_eq!(client(&base).fetch(UNSORTED), Err(ApiError::Incomplete { distinct: 51, count: 52 }));
483     }
484 
485     #[test]
486     fn a_deletion_between_reads_is_refused() {
487         // First read misses 3 of 3; meanwhile one is deleted: the union overshoots.
488         let (base, _) = server(vec![(200, "", rows(&[1, 1], 3)), (200, "", rows(&[2, 3], 2))]);
489         assert_eq!(client(&base).fetch(UNSORTED), Err(ApiError::Incomplete { distinct: 3, count: 2 }));
490     }
491 
492     #[test]
493     fn create_sends_only_link_title_and_collection() {
494         let (base, seen) = server(vec![(200, "", r#"{"result":true,"item":{"_id":77}}"#.into())]);
495         assert_eq!(client(&base).create(UNSORTED, "https://a.test/", "A").unwrap(), 77);
496         let body: serde_json::Value = serde_json::from_str(&seen.lock().unwrap()[0].1).unwrap();
497         assert_eq!(body, serde_json::json!({"link": "https://a.test/", "title": "A", "collection": {"$id": -1}}));
498     }
499 
500     #[test]
501     fn rename_is_a_title_only_partial_update() {
502         let (base, seen) = server(vec![(200, "", r#"{"result":true,"item":{}}"#.into())]);
503         client(&base).rename(5, "New").unwrap();
504         let (line, body) = seen.lock().unwrap()[0].clone();
505         assert!(line.starts_with("PUT /raindrop/5 "));
506         assert_eq!(body, r#"{"title":"New"}"#);
507     }
508 
509     #[test]
510     fn a_rate_limit_is_waited_out_once() {
511         let (base, seen) = server(vec![
512             (429, "X-RateLimit-Reset: 0\r\n", "{}".into()),
513             (200, "", r#"{"result":true}"#.into()),
514         ]);
515         client(&base).trash(9).unwrap();
516         assert_eq!(seen.lock().unwrap().len(), 2);
517     }
518 
519     #[test]
520     fn a_bad_token_stops_the_pass() {
521         let plan = Plan {
522             create_remote: vec![Local { url: "https://a.test/".into(), title: "A".into(), ts: 1 }],
523             trash_remote: vec![3],
524             ..Plan::default()
525         };
526         let (base, seen) = server(vec![(401, "", "{}".into()), (200, "", "{}".into())]);
527         assert_eq!(apply_remote(&client(&base), UNSORTED, &plan), Err(ApiError::Unauthorized));
528         assert_eq!(seen.lock().unwrap().len(), 1, "nothing more is sent after a 401");
529     }
530 
531     #[test]
532     fn one_failure_does_not_stop_the_rest() {
533         let plan = Plan {
534             create_remote: vec![
535                 Local { url: "https://a.test/".into(), title: "A".into(), ts: 1 },
536                 Local { url: "https://b.test/".into(), title: "B".into(), ts: 2 },
537             ],
538             trash_remote: vec![3],
539             ..Plan::default()
540         };
541         let (base, _) = server(vec![
542             (500, "", "boom".into()),
543             (200, "", r#"{"result":true,"item":{"_id":8}}"#.into()),
544             (404, "", "gone".into()),
545         ]);
546         let applied = apply_remote(&client(&base), UNSORTED, &plan).unwrap();
547         assert_eq!(applied.created, vec![("https://b.test/".to_string(), 8)]);
548         assert_eq!(applied.failed_trash, vec![3]);
549         assert_eq!(applied.errors.len(), 2);
550     }
551 
552     #[test]
553     fn titles_are_flattened_where_they_arrive() {
554         let r = parse_item(&serde_json::json!({"_id": 1, "link": "https://a.test/", "title": "two\nlines\tand tab"})).unwrap();
555         assert_eq!(r.title, "two lines and tab");
556     }
557 
558     #[test]
559     fn timestamps_format_and_round_trip() {
560         assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
561         assert_eq!(iso8601(951_868_800), "2000-03-01T00:00:00Z");
562         for secs in [1_790_957_045, 1_709_164_800, 4_102_444_799] {
563             assert_eq!(iso8601_secs(&iso8601(secs)), Some(secs));
564         }
565     }
566 
567     #[test]
568     fn timestamps_parse() {
569         assert_eq!(iso8601_secs("1970-01-01T00:00:00Z"), Some(0));
570         assert_eq!(iso8601_secs("2000-03-01T00:00:00.000Z"), Some(951_868_800));
571         assert_eq!(iso8601_secs("2026-10-02T16:04:05Z"), Some(1_790_957_045));
572         assert_eq!(iso8601_secs("2026-10-02 16:04:05"), None);
573         assert_eq!(iso8601_secs("2026-13-02T16:04:05Z"), None);
574     }
575 
576     #[test]
577     fn errors_never_carry_the_token() {
578         let e = client("http://127.0.0.1:1").fetch(UNSORTED).unwrap_err();
579         assert!(matches!(e, ApiError::Network(_)));
580         assert!(!e.to_string().contains("tok-123"));
581     }
582 }