use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::{Duration, Instant};
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tokio::sync::{Mutex as AsyncMutex, Semaphore};
use crate::fetcher::FetchError;
pub const API_BASE: &str = "https://api.deariary.com/api/v1";
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const RESPONSE_TTL: Duration = Duration::from_secs(60);
const MAX_CONCURRENT_REQUESTS: usize = 4;
const RETRY_AFTER_CAP: Duration = Duration::from_secs(10);
pub const MAX_LIST_LIMIT: u32 = 100;
#[derive(Debug, Clone, Deserialize)]
pub struct ApiEntry {
pub date: String,
pub title: String,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub sources: Vec<String>,
#[serde(rename = "generatedAt", default)]
pub generated_at: Option<String>,
#[serde(rename = "wordCount", default)]
pub word_count: Option<u32>,
}
pub fn entry_url(date: &str) -> String {
let path = date.replace('-', "/");
format!("https://app.deariary.com/entries/{path}")
}
pub fn token_scope(token: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(token.as_bytes());
let mut s = String::with_capacity(16);
for byte in digest.iter().take(8) {
use std::fmt::Write;
let _ = write!(s, "{byte:02x}");
}
s
}
pub fn cache_extra(opts_token: Option<&str>, raw_opts: Option<&toml::Value>) -> String {
let resolved = resolve_token(opts_token).unwrap_or_default();
let opts_str = raw_opts
.map(strip_token_field)
.and_then(|v| toml::to_string(&v).ok())
.unwrap_or_default();
format!("{}|{}", token_scope(&resolved), opts_str)
}
fn strip_token_field(value: &toml::Value) -> toml::Value {
match value {
toml::Value::Table(table) => {
let mut copy = table.clone();
copy.remove("token");
toml::Value::Table(copy)
}
other => other.clone(),
}
}
pub fn resolve_token(config_token: Option<&str>) -> Result<String, FetchError> {
let trimmed = config_token
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
.or_else(|| {
std::env::var("DEARIARY_TOKEN")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
});
trimmed.ok_or_else(|| {
FetchError::Failed("deariary token missing: set options.token or DEARIARY_TOKEN".into())
})
}
pub async fn cached_get_entry(token: &str, date: &str) -> Result<Option<ApiEntry>, FetchError> {
let slot = entry_slot(token, date);
let mut guard = slot.lock().await;
if let Some(cached) = guard.as_ref()
&& Instant::now() < cached.expires
{
return Ok(cached.value.clone());
}
let _permit = acquire_permit().await?;
let value = get_optional::<ApiEntry>(token, &format!("/entries/{date}")).await?;
*guard = Some(CacheSlot {
expires: Instant::now() + RESPONSE_TTL,
value: value.clone(),
});
Ok(value)
}
pub async fn cached_get_entries(
token: &str,
tag: Option<&str>,
) -> Result<Vec<ApiEntry>, FetchError> {
let slot = list_slot(token, tag.unwrap_or(""));
let mut guard = slot.lock().await;
if let Some(cached) = guard.as_ref()
&& Instant::now() < cached.expires
{
return Ok(cached.value.clone());
}
let _permit = acquire_permit().await?;
let mut query: Vec<(&str, String)> = vec![("limit", MAX_LIST_LIMIT.to_string())];
if let Some(t) = tag.filter(|s| !s.is_empty()) {
query.push(("tag", t.to_string()));
}
let response: EntriesResponse = get_required(token, "/entries", &query).await?;
let value = response.into_vec();
*guard = Some(CacheSlot {
expires: Instant::now() + RESPONSE_TTL,
value: value.clone(),
});
Ok(value)
}
fn http() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.build()
.expect("reqwest client should build with default config")
})
}
fn semaphore() -> &'static Semaphore {
static S: OnceLock<Semaphore> = OnceLock::new();
S.get_or_init(|| Semaphore::new(MAX_CONCURRENT_REQUESTS))
}
async fn acquire_permit() -> Result<tokio::sync::SemaphorePermit<'static>, FetchError> {
semaphore()
.acquire()
.await
.map_err(|e| FetchError::Failed(format!("deariary semaphore poisoned: {e}")))
}
struct CacheSlot<T> {
expires: Instant,
value: T,
}
type EntrySlot = Arc<AsyncMutex<Option<CacheSlot<Option<ApiEntry>>>>>;
type ListSlot = Arc<AsyncMutex<Option<CacheSlot<Vec<ApiEntry>>>>>;
fn entry_cache() -> &'static StdMutex<HashMap<String, EntrySlot>> {
static C: OnceLock<StdMutex<HashMap<String, EntrySlot>>> = OnceLock::new();
C.get_or_init(|| StdMutex::new(HashMap::new()))
}
fn list_cache() -> &'static StdMutex<HashMap<String, ListSlot>> {
static C: OnceLock<StdMutex<HashMap<String, ListSlot>>> = OnceLock::new();
C.get_or_init(|| StdMutex::new(HashMap::new()))
}
fn entry_slot(token: &str, date: &str) -> EntrySlot {
entry_cache()
.lock()
.unwrap()
.entry(format!("{}|{date}", token_scope(token)))
.or_insert_with(|| Arc::new(AsyncMutex::new(None)))
.clone()
}
fn list_slot(token: &str, tag: &str) -> ListSlot {
list_cache()
.lock()
.unwrap()
.entry(format!("{}|{tag}", token_scope(token)))
.or_insert_with(|| Arc::new(AsyncMutex::new(None)))
.clone()
}
async fn get_optional<T: DeserializeOwned>(
token: &str,
path: &str,
) -> Result<Option<T>, FetchError> {
let bytes = match send_with_retry(token, path, &[]).await? {
FetchOutcome::NotFound => return Ok(None),
FetchOutcome::Body(b) => b,
};
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| FetchError::Failed(format!("deariary json parse: {e}")))
}
async fn get_required<T: DeserializeOwned>(
token: &str,
path: &str,
query: &[(&str, String)],
) -> Result<T, FetchError> {
match send_with_retry(token, path, query).await? {
FetchOutcome::NotFound => Err(FetchError::Failed(format!("deariary {path}: 404"))),
FetchOutcome::Body(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| FetchError::Failed(format!("deariary json parse: {e}"))),
}
}
enum FetchOutcome {
NotFound,
Body(Vec<u8>),
}
async fn send_with_retry(
token: &str,
path: &str,
query: &[(&str, String)],
) -> Result<FetchOutcome, FetchError> {
let mut attempt = 0u8;
loop {
let url = format!("{API_BASE}{path}");
let res = http()
.get(&url)
.bearer_auth(token)
.query(query)
.send()
.await
.map_err(|e| FetchError::Failed(format!("deariary request failed: {e}")))?;
let status = res.status();
if status == StatusCode::NOT_FOUND {
return Ok(FetchOutcome::NotFound);
}
if status == StatusCode::TOO_MANY_REQUESTS && attempt == 0 {
let wait = parse_retry_after(res.headers()).min(RETRY_AFTER_CAP);
tracing::warn!(
limit = header_str(res.headers(), "X-RateLimit-Limit").as_deref(),
remaining = header_str(res.headers(), "X-RateLimit-Remaining").as_deref(),
reset = header_str(res.headers(), "X-RateLimit-Reset").as_deref(),
retry_after_s = wait.as_secs(),
path,
"deariary 429: backing off before retry",
);
tokio::time::sleep(wait).await;
attempt += 1;
continue;
}
let bytes = res
.bytes()
.await
.map_err(|e| FetchError::Failed(format!("deariary response body: {e}")))?;
if !status.is_success() {
return Err(FetchError::Failed(error_message(status, &bytes)));
}
return Ok(FetchOutcome::Body(bytes.to_vec()));
}
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Duration {
headers
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(1))
}
fn header_str(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum EntriesResponse {
Wrapped { data: Vec<ApiEntry> },
Plain(Vec<ApiEntry>),
}
impl EntriesResponse {
fn into_vec(self) -> Vec<ApiEntry> {
match self {
Self::Wrapped { data } => data,
Self::Plain(items) => items,
}
}
}
fn error_message(status: StatusCode, body: &[u8]) -> String {
#[derive(Deserialize)]
struct Problem {
#[serde(default)]
title: Option<String>,
#[serde(default)]
detail: Option<String>,
}
let reported = serde_json::from_slice::<Problem>(body)
.ok()
.and_then(|p| p.detail.or(p.title));
match reported {
Some(m) => format!("deariary {status}: {m}"),
None => format!("deariary {status}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entry(date: &str, title: &str) -> ApiEntry {
ApiEntry {
date: date.into(),
title: title.into(),
content: None,
tags: vec![],
sources: vec![],
generated_at: None,
word_count: None,
}
}
#[test]
fn entries_response_accepts_bare_array() {
let raw = r#"[{"date":"2026-04-27","title":"Hello"}]"#;
let parsed: EntriesResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.into_vec().len(), 1);
}
#[test]
fn entries_response_accepts_wrapped_data() {
let raw = r#"{"data":[{"date":"2026-04-27","title":"Hello"}]}"#;
let parsed: EntriesResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.into_vec().len(), 1);
}
#[test]
fn api_entry_tolerates_missing_optional_fields() {
let raw = r#"{"date":"2026-04-27","title":"Hello"}"#;
let entry: ApiEntry = serde_json::from_str(raw).unwrap();
assert_eq!(entry.date, "2026-04-27");
assert!(entry.content.is_none());
assert!(entry.tags.is_empty());
assert!(entry.word_count.is_none());
}
#[test]
fn api_entry_reads_full_payload() {
let raw = r##"{
"date":"2026-04-27",
"title":"My Day",
"content":"# Hello\n\nWorld",
"tags":["work","travel"],
"sources":["github","calendar"],
"generatedAt":"2026-04-27T08:00:00Z",
"wordCount":482
}"##;
let entry: ApiEntry = serde_json::from_str(raw).unwrap();
assert_eq!(entry.tags, vec!["work", "travel"]);
assert_eq!(entry.sources, vec!["github", "calendar"]);
assert_eq!(entry.word_count, Some(482));
assert_eq!(entry.generated_at.as_deref(), Some("2026-04-27T08:00:00Z"));
}
#[test]
fn error_message_picks_problem_detail_over_title() {
let body = br#"{"title":"Bad Request","detail":"date out of range"}"#;
let msg = error_message(StatusCode::BAD_REQUEST, body);
assert!(msg.contains("date out of range"));
}
#[test]
fn error_message_falls_back_to_title_when_detail_missing() {
let body = br#"{"title":"Forbidden"}"#;
let msg = error_message(StatusCode::FORBIDDEN, body);
assert!(msg.contains("Forbidden"));
}
#[test]
fn error_message_handles_non_problem_body() {
let msg = error_message(StatusCode::INTERNAL_SERVER_ERROR, b"internal explosion");
assert!(msg.contains("500"));
}
#[test]
fn resolve_token_prefers_config_value_over_env() {
let token = resolve_token(Some("from-config")).unwrap();
assert_eq!(token, "from-config");
}
#[test]
fn entry_url_uses_app_subdomain_with_slash_separated_date() {
assert_eq!(
entry_url("2026-04-27"),
"https://app.deariary.com/entries/2026/04/27"
);
}
#[test]
fn token_scope_is_stable_collision_resistant_and_obscures_raw_token() {
let a = token_scope("tok-A");
let b = token_scope("tok-A");
assert_eq!(a, b, "scope must be deterministic for repeat calls");
assert_ne!(token_scope("tok-A"), token_scope("tok-B"));
assert_eq!(a.len(), 16);
assert!(!a.contains("tok-A"));
}
#[test]
fn token_scope_dodges_naive_pipe_separator_collision() {
assert_ne!(token_scope("a|b"), token_scope("a"));
}
#[test]
fn cache_extra_partitions_disk_cache_per_resolved_token() {
let a = cache_extra(Some("tok-A"), None);
let b = cache_extra(Some("tok-B"), None);
assert_ne!(
a, b,
"different config tokens must yield different cache extras",
);
}
#[test]
fn cache_extra_includes_options_blob_for_per_invocation_keys() {
let v1: toml::Value = toml::from_str("tag = \"work\"").unwrap();
let v2: toml::Value = toml::from_str("tag = \"travel\"").unwrap();
let a = cache_extra(Some("tok-A"), Some(&v1));
let b = cache_extra(Some("tok-A"), Some(&v2));
assert_ne!(a, b);
}
#[test]
fn cache_extra_strips_token_from_options_blob() {
let opts: toml::Value = toml::from_str("token = \"super-secret\"\ntag = \"work\"").unwrap();
let extra = cache_extra(Some("super-secret"), Some(&opts));
assert!(
!extra.contains("super-secret"),
"raw token must not appear in extra: {extra:?}"
);
assert!(extra.contains("tag = \"work\""), "got: {extra:?}");
}
#[test]
fn cache_extra_includes_a_token_scope_prefix() {
let extra = cache_extra(Some("tok-A"), None);
let scope = token_scope("tok-A");
assert!(extra.starts_with(&format!("{scope}|")), "got: {extra:?}");
}
#[test]
fn strip_token_field_preserves_non_table_values() {
let value = toml::Value::String("plain".into());
assert_eq!(strip_token_field(&value), value);
}
#[test]
fn strip_token_field_removes_only_top_level_token() {
let value: toml::Value = toml::from_str(
r#"
token = "top-secret"
tag = "work"
[nested]
token = "keep-me"
"#,
)
.unwrap();
let stripped = strip_token_field(&value);
let table = stripped.as_table().unwrap();
assert!(!table.contains_key("token"));
assert_eq!(table.get("tag").and_then(toml::Value::as_str), Some("work"));
assert_eq!(
table["nested"].get("token").and_then(toml::Value::as_str),
Some("keep-me")
);
}
#[tokio::test]
async fn entry_slot_returns_same_arc_for_same_token_and_date() {
let a = entry_slot("tok-A", "2026-04-27");
let b = entry_slot("tok-A", "2026-04-27");
assert!(Arc::ptr_eq(&a, &b));
let c = entry_slot("tok-A", "2026-04-26");
assert!(!Arc::ptr_eq(&a, &c));
}
#[tokio::test]
async fn entry_slot_isolates_per_token() {
let a = entry_slot("tok-A", "2026-04-27");
let b = entry_slot("tok-B", "2026-04-27");
assert!(
!Arc::ptr_eq(&a, &b),
"different tokens must not share a cache slot",
);
}
#[tokio::test]
async fn list_slot_returns_same_arc_for_same_token_and_tag() {
let a = list_slot("tok-A", "travel");
let b = list_slot("tok-A", "travel");
assert!(Arc::ptr_eq(&a, &b));
let c = list_slot("tok-A", "");
assert!(!Arc::ptr_eq(&a, &c));
}
#[tokio::test]
async fn list_slot_isolates_per_token() {
let a = list_slot("tok-A", "travel");
let b = list_slot("tok-B", "travel");
assert!(
!Arc::ptr_eq(&a, &b),
"different tokens must not share a list cache slot",
);
}
#[tokio::test]
async fn cached_get_entry_reuses_fresh_slot_value() {
let token = "tok-cache-entry";
let date = "2026-04-28";
let entry = sample_entry(date, "Cached");
let slot = entry_slot(token, date);
*slot.lock().await = Some(CacheSlot {
expires: Instant::now() + RESPONSE_TTL,
value: Some(entry.clone()),
});
let cached = cached_get_entry(token, date).await.unwrap();
assert_eq!(cached.unwrap().title, entry.title);
}
#[tokio::test]
async fn cached_get_entries_reuses_fresh_slot_value() {
let token = "tok-cache-list";
let tag = "travel";
let entries = vec![
sample_entry("2026-04-28", "First"),
sample_entry("2026-04-27", "Second"),
];
let slot = list_slot(token, tag);
*slot.lock().await = Some(CacheSlot {
expires: Instant::now() + RESPONSE_TTL,
value: entries.clone(),
});
let cached = cached_get_entries(token, Some(tag)).await.unwrap();
assert_eq!(cached.len(), entries.len());
assert_eq!(cached[0].title, entries[0].title);
}
#[test]
fn resolve_token_trims_whitespace() {
let token = resolve_token(Some(" abc ")).unwrap();
assert_eq!(token, "abc");
}
#[test]
fn resolve_token_falls_back_to_env_and_trims_whitespace() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("DEARIARY_TOKEN").ok();
unsafe { std::env::set_var("DEARIARY_TOKEN", " env-token ") };
let token = resolve_token(None).unwrap();
assert_eq!(token, "env-token");
unsafe {
match previous {
Some(value) => std::env::set_var("DEARIARY_TOKEN", value),
None => std::env::remove_var("DEARIARY_TOKEN"),
}
}
}
#[test]
fn resolve_token_whitespace_config_falls_back_to_env() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("DEARIARY_TOKEN").ok();
unsafe { std::env::set_var("DEARIARY_TOKEN", "env-token") };
let token = resolve_token(Some(" ")).unwrap();
assert_eq!(token, "env-token");
unsafe {
match previous {
Some(value) => std::env::set_var("DEARIARY_TOKEN", value),
None => std::env::remove_var("DEARIARY_TOKEN"),
}
}
}
#[test]
fn resolve_token_errors_when_config_and_env_are_missing() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("DEARIARY_TOKEN").ok();
unsafe { std::env::remove_var("DEARIARY_TOKEN") };
let err = resolve_token(Some(" ")).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message)
if message == "deariary token missing: set options.token or DEARIARY_TOKEN"
));
unsafe {
match previous {
Some(value) => std::env::set_var("DEARIARY_TOKEN", value),
None => std::env::remove_var("DEARIARY_TOKEN"),
}
}
}
#[test]
fn cache_extra_uses_env_token_scope_when_config_token_is_missing() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("DEARIARY_TOKEN").ok();
let opts: toml::Value = toml::from_str("tag = \"work\"").unwrap();
unsafe { std::env::set_var("DEARIARY_TOKEN", "env-token") };
let extra = cache_extra(None, Some(&opts));
assert!(extra.starts_with(&format!("{}|", token_scope("env-token"))));
assert!(extra.contains("tag = \"work\""));
assert!(!extra.contains("env-token"));
unsafe {
match previous {
Some(value) => std::env::set_var("DEARIARY_TOKEN", value),
None => std::env::remove_var("DEARIARY_TOKEN"),
}
}
}
#[test]
fn cache_extra_uses_empty_scope_when_token_resolution_fails() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("DEARIARY_TOKEN").ok();
let opts: toml::Value = toml::from_str("tag = \"work\"").unwrap();
unsafe { std::env::remove_var("DEARIARY_TOKEN") };
let extra = cache_extra(None, Some(&opts));
assert!(extra.starts_with(&format!("{}|", token_scope(""))));
assert!(extra.contains("tag = \"work\""));
unsafe {
match previous {
Some(value) => std::env::set_var("DEARIARY_TOKEN", value),
None => std::env::remove_var("DEARIARY_TOKEN"),
}
}
}
#[tokio::test]
async fn acquire_permit_succeeds_from_shared_semaphore() {
assert!(std::ptr::eq(http(), http()));
assert!(std::ptr::eq(semaphore(), semaphore()));
let _permit = acquire_permit().await.unwrap();
}
#[test]
fn parse_retry_after_parses_seconds_and_defaults_on_invalid() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::RETRY_AFTER, "7".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Duration::from_secs(7));
headers.insert(reqwest::header::RETRY_AFTER, "bad".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Duration::from_secs(1));
}
#[test]
fn parse_retry_after_trims_values_and_defaults_when_missing() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::RETRY_AFTER, " 9 ".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Duration::from_secs(9));
let missing = reqwest::header::HeaderMap::new();
assert_eq!(parse_retry_after(&missing), Duration::from_secs(1));
}
#[test]
fn header_str_returns_owned_utf8_values_only() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("X-RateLimit-Limit", "120".parse().unwrap());
headers.insert(
"X-Binary",
reqwest::header::HeaderValue::from_bytes(b"\xff").unwrap(),
);
assert_eq!(
header_str(&headers, "X-RateLimit-Limit").as_deref(),
Some("120")
);
assert!(header_str(&headers, "X-Binary").is_none());
}
#[test]
fn header_str_returns_none_when_header_is_missing() {
let headers = reqwest::header::HeaderMap::new();
assert!(header_str(&headers, "X-RateLimit-Limit").is_none());
}
#[tokio::test]
async fn cached_get_entry_propagates_send_failure_when_header_value_invalid() {
let err = cached_get_entry("tok\nbreak-A", "2026-04-27")
.await
.unwrap_err();
assert!(
matches!(&err, FetchError::Failed(msg) if msg.starts_with("deariary request failed:")),
"got {err:?}",
);
}
#[tokio::test]
async fn cached_get_entry_falls_through_expired_slot_then_propagates_error() {
let token = "tok\nbreak-B";
let date = "2026-04-27";
let slot = entry_slot(token, date);
*slot.lock().await = Some(CacheSlot {
expires: Instant::now() - Duration::from_secs(1),
value: Some(sample_entry(date, "stale")),
});
drop(slot);
let err = cached_get_entry(token, date).await.unwrap_err();
assert!(
matches!(&err, FetchError::Failed(msg) if msg.starts_with("deariary request failed:")),
"got {err:?}",
);
}
#[tokio::test]
async fn cached_get_entries_falls_through_expired_slot_then_propagates_error() {
let token = "tok\nbreak-C";
let tag = "travel";
let slot = list_slot(token, tag);
*slot.lock().await = Some(CacheSlot {
expires: Instant::now() - Duration::from_secs(1),
value: vec![sample_entry("2026-04-27", "stale")],
});
drop(slot);
let err = cached_get_entries(token, Some(tag)).await.unwrap_err();
assert!(
matches!(&err, FetchError::Failed(msg) if msg.starts_with("deariary request failed:")),
"got {err:?}",
);
}
#[tokio::test]
async fn send_with_retry_surfaces_send_failure_for_invalid_header_token() {
match send_with_retry("tok\nbreak-D", "/entries/2026-04-27", &[]).await {
Err(FetchError::Failed(msg)) => {
assert!(msg.starts_with("deariary request failed:"), "got {msg}");
}
Err(other) => panic!("expected Failed, got {other:?}"),
Ok(_) => panic!("expected request-failure error, got success"),
}
}
#[tokio::test]
async fn get_optional_propagates_send_failure_before_json_parse() {
let err: FetchError = get_optional::<ApiEntry>("tok\nbreak-E", "/entries/2026-04-27")
.await
.unwrap_err();
assert!(
matches!(&err, FetchError::Failed(msg) if msg.starts_with("deariary request failed:")),
"got {err:?}",
);
}
#[tokio::test]
async fn get_required_propagates_send_failure_before_json_parse() {
let err: FetchError =
get_required::<EntriesResponse>("tok\nbreak-F", "/entries", &[("limit", "100".into())])
.await
.unwrap_err();
assert!(
matches!(&err, FetchError::Failed(msg) if msg.starts_with("deariary request failed:")),
"got {err:?}",
);
}
}