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 }