//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155. //! //! Commands: //! - `compute_account_return` (Issue #142): Modified Dietz return for one //! account over a period. Reads snapshot endpoints + linked transfer amounts //! in a single Rust pass. //! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for //! a given `(symbol, date)` pair. Privacy-strict: sends only //! `Authorization`, `Accept`, and `User-Agent` headers. //! //! Database access pattern: //! - All reads open the file `profile_db_path` resolves (`profile_commands.rs`): //! the folder where tauri-plugin-sql keeps the databases, which is NOT //! `app_data_dir` on Linux (#390). //! - The frontend passes `db_filename` (the active profile DB), exactly //! like it does for `repair_migrations` and `delete_profile_db`. Keeps //! the active-profile resolution where it already lives (in TS) and //! avoids re-reading `profiles.json` on every call. //! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements, //! drops at end of function. No connection pooling needed (commands run //! on the Tauri async runtime, one at a time per invocation). use chrono::NaiveDate; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use std::path::Path; use tauri::Manager; use crate::commands::license_commands::{remove_activation_token_if_unchanged_in, ACTIVATION_FILE}; use crate::commands::return_calculator::{modified_dietz, AccountReturn}; // --------------------------------------------------------------------------- // fetch_price types (Issue #155) // --------------------------------------------------------------------------- /// Successful price response from `GET /v1/prices`. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PriceResponse { pub symbol: String, pub date: String, pub actual_date: Option, pub price: f64, pub currency: String, pub source: String, pub fetched_at: String, pub cached: bool, } /// Typed error returned by `fetch_price`. Serialized as JSON to cross the /// Tauri command boundary (the JS layer `JSON.parse`s the error string). /// /// The `tag = "code"` + `rename_all = "snake_case"` combination produces /// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. — /// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`. #[derive(Debug, Serialize, Clone)] #[serde(tag = "code", rename_all = "snake_case")] pub enum FetchPriceError { Auth, PremiumRequired, /// 403 `license_revoked`: the license the activation token stands for was revoked. /// Named for the user, never recorded as the state of the stored key: the token names /// a license the client cannot tie to that key, and a data directory written by 0.15.0 /// or earlier can hold a valid key next to the token of a previous license (#365). The /// refused token is dropped instead (#367, [`fetch_price_in`]). LicenseRevoked, SymbolNotFound, RateLimit { retry_after_s: u64 }, ProviderUnavailable, Network, Internal, } impl std::fmt::Display for FetchPriceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FetchPriceError::Auth => write!(f, "auth"), FetchPriceError::PremiumRequired => write!(f, "premium_required"), FetchPriceError::LicenseRevoked => write!(f, "license_revoked"), FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"), FetchPriceError::RateLimit { retry_after_s } => { write!(f, "rate_limit (retry after {}s)", retry_after_s) } FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"), FetchPriceError::Network => write!(f, "network"), FetchPriceError::Internal => write!(f, "internal"), } } } /// Serialize a `FetchPriceError` to the stable JSON string returned across /// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization /// failure (which should never happen in practice). fn price_error_to_string(err: &FetchPriceError) -> String { serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string()) } /// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests /// and development environments. fn base_url() -> String { std::env::var("MAXIMUS_API_URL") .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) } /// Read the activation token stored in `dir` (raw JWT string, trimmed). /// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable: nothing is sent /// then, and the message invites the user to activate this machine. fn read_stored_activation_token_in(dir: &Path) -> Result { std::fs::read_to_string(dir.join(ACTIVATION_FILE)) .map(|s| s.trim().to_string()) .map_err(|_| FetchPriceError::Auth) } /// The price call as the command performs it, without `AppHandle` so tests run it in a /// temporary directory and against a mock server: the stored activation token is read, then /// sent to `api_base`. /// /// A 403 `license_revoked` condemns that token (#367): the license it was issued for is /// revoked, so it will never fetch a price again, whatever key is stored. It is dropped and /// the License card offers "Activate" again, but only while the file still holds it: an /// activation that lands while the refused request is in flight writes a new token, which /// stays (CWE-367). Best effort: the refusal reaches the user whatever happens to the file. /// The revoked state of the stored key is never written from here: this route judges the /// license of the token, which the client cannot tie to the key (#365). pub(crate) async fn fetch_price_in( dir: &Path, symbol: &str, date: &str, api_base: &str, ) -> Result { let token = read_stored_activation_token_in(dir)?; let result = fetch_price_inner(&token, symbol, date, api_base).await; if matches!(result, Err(FetchPriceError::LicenseRevoked)) { let _ = remove_activation_token_if_unchanged_in(dir, &token); } result } /// The HTTP call alone, separated so tests can inject an arbitrary token string and base URL /// without touching the file system. Tests pass an explicit `api_base` to avoid env-var races /// between concurrent test threads. async fn fetch_price_inner( token: &str, symbol: &str, date: &str, api_base: &str, ) -> Result { let url = format!( "{}/v1/prices?symbol={}&date={}", api_base, urlencoding::encode(symbol), urlencoding::encode(date), ); // Build client with User-Agent set on the builder — NOT as a manual header. // This satisfies the privacy contract (§3.1): UA is set at the transport // level, not injected as an explicit per-request header alongside // Accept-Language, cookies, or other identifying headers. let client = reqwest::Client::builder() .user_agent("simpl-resultat") .build() .map_err(|_| FetchPriceError::Internal)?; let resp = client .get(&url) .header("Authorization", format!("Bearer {}", token)) .header("Accept", "application/json") // DO NOT add User-Agent here — it is already set on the client builder. .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|_| FetchPriceError::Network)?; let status = resp.status(); match status.as_u16() { 200 => { let price_resp: PriceResponse = resp .json() .await .map_err(|_| FetchPriceError::Internal)?; Ok(price_resp) } 401 => Err(FetchPriceError::Auth), 403 => { // The price route refuses for two reasons (api-contract-prices.md §5): a // revoked license, tested first by the server, or an edition below Premium. // Only `error.code` tells them apart; an unreadable body keeps the old reading. let body: serde_json::Value = resp .json() .await .unwrap_or(serde_json::Value::Null); match body.pointer("/error/code").and_then(|v| v.as_str()) { Some("license_revoked") => Err(FetchPriceError::LicenseRevoked), _ => Err(FetchPriceError::PremiumRequired), } } 404 => Err(FetchPriceError::SymbolNotFound), 429 => { // Parse `error.retry_after` from the JSON body. let body: serde_json::Value = resp .json() .await .unwrap_or(serde_json::Value::Null); let retry_after_s = body .pointer("/error/retry_after") .and_then(|v| v.as_u64()) .unwrap_or(60); Err(FetchPriceError::RateLimit { retry_after_s }) } s if s >= 500 => Err(FetchPriceError::ProviderUnavailable), _ => Err(FetchPriceError::Internal), } } /// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api. /// /// Reads the stored activation token, then calls `GET /v1/prices`; a token refused as issued /// for a revoked license is dropped ([`fetch_price_in`], #367). Returns a serialized /// `FetchPriceError` JSON string on error so the JS layer can `JSON.parse` and branch on /// `code`. /// /// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent` /// are sent. `User-Agent` is set on the reqwest client builder — not injected /// as a manual header — so no fingerprinting headers leak. #[tauri::command] pub async fn fetch_price( app: tauri::AppHandle, symbol: String, date: String, ) -> Result { let dir = app .path() .app_data_dir() .map_err(|_| price_error_to_string(&FetchPriceError::Auth))?; fetch_price_in(&dir, &symbol, &date, &base_url()) .await .map_err(|e| price_error_to_string(&e)) } /// Compute the Modified Dietz return for one account over the period /// `[period_start, period_end]`. Reads: /// - `value_start`: latest snapshot line for the account whose /// `snapshot_date <= period_start` (None if no prior snapshot). /// - `value_end`: latest snapshot line for the account whose /// `snapshot_date <= period_end` (None if no snapshot in range). /// - cash flows: every linked transfer in `[period_start, period_end]`, /// sign applied per direction (`in` → `+`, `out` → `−`). /// /// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn` /// (Serialize) ready to ship across the Tauri boundary. #[tauri::command] pub fn compute_account_return( app: tauri::AppHandle, db_filename: String, account_id: i64, period_start: String, period_end: String, ) -> Result { let start_date = parse_iso_date(&period_start, "period_start")?; let end_date = parse_iso_date(&period_end, "period_end")?; let db_path = super::profile_commands::profile_db_path(&app, &db_filename)?; if !db_path.exists() { // The file name only: the folder holds the OS user name. return Err(format!("Profile database not found: {}", db_filename)); } let conn = Connection::open(&db_path) .map_err(|e| format!("Cannot open database: {}", e))?; let value_start = read_value_at_or_before(&conn, account_id, &period_start)?; let value_end = read_value_at_or_before(&conn, account_id, &period_end)?; let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?; Ok(modified_dietz( value_start, value_end, &cash_flows, start_date, end_date, )) } // ----------------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------------- fn parse_iso_date(input: &str, field: &str) -> Result { NaiveDate::parse_from_str(input, "%Y-%m-%d") .map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e)) } /// Reads the value of the snapshot line for `account_id` at the most recent /// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no /// such snapshot exists for this account. fn read_value_at_or_before( conn: &Connection, account_id: i64, as_of_date: &str, ) -> Result, String> { // Single-row query: pick the latest snapshot date for this account that // is on or before `as_of_date`, then return that line's value. Indexed // on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`. let mut stmt = conn .prepare( "SELECT l.value FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = ?1 AND s.snapshot_date <= ?2 ORDER BY s.snapshot_date DESC LIMIT 1", ) .map_err(|e| format!("prepare value query: {}", e))?; let mut rows = stmt .query(rusqlite::params![account_id, as_of_date]) .map_err(|e| format!("execute value query: {}", e))?; match rows.next().map_err(|e| format!("read value row: {}", e))? { Some(row) => Ok(Some( row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?, )), None => Ok(None), } } /// Reads every linked transfer for `account_id` whose underlying /// transaction's `transaction_date` falls inside `[period_start, period_end]`. /// Returns `(NaiveDate, signed_amount)` — sign applied per `direction` /// (`in` → `+`, `out` → `−`). Amounts come from the linked transaction. fn read_cash_flows( conn: &Connection, account_id: i64, period_start: &str, period_end: &str, ) -> Result, String> { // NOTE: the transactions table column is `date` (not `transaction_date`). // See `src-tauri/src/database/schema.sql:67`. let mut stmt = conn .prepare( "SELECT t.date, ABS(t.amount) AS abs_amount, bat.direction FROM balance_account_transfers bat JOIN transactions t ON t.id = bat.transaction_id WHERE bat.account_id = ?1 AND t.date BETWEEN ?2 AND ?3 ORDER BY t.date", ) .map_err(|e| format!("prepare flows query: {}", e))?; let rows = stmt .query_map( rusqlite::params![account_id, period_start, period_end], |row| { // `transactions.date` may come back as String (TEXT) — keep // the decoder generic enough. let date_str: String = row.get(0)?; let amount: f64 = row.get(1)?; let direction: String = row.get(2)?; Ok((date_str, amount, direction)) }, ) .map_err(|e| format!("execute flows query: {}", e))?; let mut flows: Vec<(NaiveDate, f64)> = Vec::new(); for row_result in rows { let (date_str, amount, direction) = row_result.map_err(|e| format!("decode flow row: {}", e))?; // `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column — // see consolidated_schema.sql). Defensive trim of any trailing // time component just in case. let iso = date_str.split('T').next().unwrap_or(&date_str).to_string(); let date = parse_iso_date(&iso, "transaction_date")?; let signed = match direction.as_str() { "in" => amount, "out" => -amount, other => { return Err(format!( "Invalid transfer direction stored in DB: {}", other )); } }; flows.push((date, signed)); } Ok(flows) } // ============================================================================= // Tests for fetch_price (Issue #155) // ============================================================================= // // Strategy: use `mockito::Server::new_async()` as an in-process HTTP server. // Each test calls `fetch_price_inner` directly, passing the mock server URL // as `api_base`. This avoids env-var races between concurrent test threads // (all tokio tests share the same process) and bypasses the file-system // activation token loading. The tests of the stored token (#367) call // `fetch_price_in` instead, with a temporary data directory. #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn it_returns_price_on_200() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#, ) .create_async() .await; let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await; assert!(result.is_ok(), "expected Ok, got {:?}", result); let resp = result.unwrap(); assert_eq!(resp.symbol, "AAPL"); assert!((resp.price - 173.45).abs() < f64::EPSILON); assert_eq!(resp.currency, "USD"); assert!(!resp.cached); } #[tokio::test] async fn it_returns_auth_error_on_401() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(401) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#) .create_async() .await; let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed["code"], "auth"); } #[tokio::test] async fn it_returns_premium_required_on_403() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(403) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#) .create_async() .await; let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed["code"], "premium_required"); } /// #365: a revoked license used to read as "premium required", because every 403 did. #[tokio::test] async fn it_names_a_revoked_license_on_403() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(403) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#) .create_async() .await; let result = fetch_price_inner("revoked-token", "AAPL", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed, serde_json::json!({ "code": "license_revoked" })); } /// A 403 whose body cannot be read, or whose code is not known, keeps the old reading. #[tokio::test] async fn it_keeps_premium_required_for_any_other_403() { let bodies = [ ("text/html", "Forbidden"), ("application/json", r#"{"error":{"code":"something_else","message":"x"}}"#), ("application/json", r#"{"code":"license_revoked"}"#), ]; for (content_type, body) in bodies { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(403) .with_header("content-type", content_type) .with_body(body) .create_async() .await; let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed["code"], "premium_required", "body {}", body); } } #[tokio::test] async fn it_returns_symbol_not_found_on_404() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(404) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#) .create_async() .await; let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed["code"], "symbol_not_found"); } #[tokio::test] async fn it_parses_retry_after_on_429() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(429) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#) .create_async() .await; let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed["code"], "rate_limit"); assert_eq!(parsed["retry_after_s"], 42); } #[tokio::test] async fn it_returns_provider_unavailable_on_502() { let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(502) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#) .create_async() .await; let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await; let err_str = price_error_to_string(&result.unwrap_err()); let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); assert_eq!(parsed["code"], "provider_unavailable"); } /// Privacy assertion: the request must only carry `Authorization`, `Accept`, /// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*` /// tracking headers. /// /// mockito's `match_header` with `Matcher::Missing` asserts that a header /// is absent from the request. We assert absence for each forbidden header. #[tokio::test] async fn it_sends_only_authorization_accept_user_agent() { let mut server = mockito::Server::new_async().await; // Forbidden headers — must be absent from every request. let forbidden = [ "cookie", "accept-language", "x-forwarded-for", "x-real-ip", "x-custom-tracking", ]; let mut mock_builder = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#, ); for header in &forbidden { mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing); } // Also assert the required headers ARE present. mock_builder = mock_builder .match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string())) .match_header("accept", "application/json") .match_header("user-agent", "simpl-resultat"); let _m = mock_builder.create_async().await; let result = fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await; assert!( result.is_ok(), "expected Ok for privacy test, got {:?}", result ); // If any forbidden header was present, mockito would return 501 and the // JSON parse would fail. A successful 200 parse confirms the privacy contract. assert_eq!(result.unwrap().symbol, "BTC"); } // === The stored token, and a token refused as issued for a revoked license (#367) ========== /// A data directory of its own for each test (test name + pid: cargo runs tests on parallel /// threads), removed when the test ends, failed assertion included. struct TestDir(std::path::PathBuf); impl TestDir { fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!( "simpl-resultat-price-test-{}-{}", name, std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); TestDir(dir) } fn write_token(&self, contents: &str) { std::fs::write(self.0.join(ACTIVATION_FILE), contents).unwrap(); } fn token(&self) -> Option { std::fs::read_to_string(self.0.join(ACTIVATION_FILE)).ok() } } impl Drop for TestDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } const REVOKED: &str = r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#; /// `ok`, or the code the front reads from the error. fn outcome(result: &Result) -> String { match result { Ok(_) => "ok".to_string(), Err(e) => serde_json::from_str::(&price_error_to_string(e)).unwrap() ["code"] .as_str() .unwrap() .to_string(), } } #[tokio::test] async fn a_revoked_license_drops_the_token_it_refused() { // The license of this token is revoked: it will never fetch a price again, and the card // must offer "Activate". Written with a newline, as an older version may have left it, // read from `ACTIVATION_FILE` and sent trimmed. Nothing is written in its place: the // key's state is not this route's to record (#365). let dir = TestDir::new("drops_revoked_token"); dir.write_token("token-of-a-revoked-license\n"); let mut server = mockito::Server::new_async().await; let route = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .match_header("authorization", "Bearer token-of-a-revoked-license") .with_status(403) .with_header("content-type", "application/json") .with_body(REVOKED) .expect(1) .create_async() .await; let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await; assert_eq!(outcome(&result), "license_revoked"); route.assert_async().await; assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "the token gone, nothing written"); } #[tokio::test] async fn a_token_written_while_the_refused_request_was_in_flight_stays() { // An activation lands while the refused request is in flight (CWE-367): the refusal is // about the token that was sent, not the one written meanwhile. let dir = TestDir::new("token_replaced_in_flight"); dir.write_token("refused-token"); let token_path = dir.0.join(ACTIVATION_FILE); let mut server = mockito::Server::new_async().await; let route = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .match_request(move |_request| { std::fs::write(&token_path, "fresh-token").unwrap(); true }) .with_status(403) .with_header("content-type", "application/json") .with_body(REVOKED) .expect(1) .create_async() .await; let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await; assert_eq!(outcome(&result), "license_revoked"); route.assert_async().await; assert_eq!(dir.token().as_deref(), Some("fresh-token")); } #[tokio::test] async fn no_other_answer_drops_the_token() { // Only a revoked license condemns the token: a Base license, a token the server does // not accept (the user is asked to activate), a missing symbol and every outage leave // it as it was. let rows: Vec<(usize, &str, &str)> = vec![ ( 200, r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#, "ok", ), (401, r#"{"error":{"code":"invalid_token","message":"x"}}"#, "auth"), (403, r#"{"error":{"code":"premium_required","message":"x"}}"#, "premium_required"), (403, "Forbidden", "premium_required"), (404, r#"{"error":{"code":"symbol_not_found","message":"x"}}"#, "symbol_not_found"), ( 429, r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#, "rate_limit", ), (502, r#"{"error":{"code":"provider_unavailable","message":"x"}}"#, "provider_unavailable"), ]; for (status, body, expected) in rows { let dir = TestDir::new("keeps_token"); dir.write_token("token"); let mut server = mockito::Server::new_async().await; let route = server .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) .with_status(status) .with_header("content-type", "application/json") .with_body(body) .expect(1) .create_async() .await; let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await; assert_eq!(outcome(&result), expected, "{} {}", status, body); route.assert_async().await; assert_eq!(dir.token().as_deref(), Some("token"), "{} {}", status, body); } // Offline. Port 1 on loopback: nothing listens. let dir = TestDir::new("keeps_token_offline"); dir.write_token("token"); let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", "http://127.0.0.1:1").await; assert_eq!(outcome(&result), "network"); assert_eq!(dir.token().as_deref(), Some("token")); } #[tokio::test] async fn without_a_token_nothing_is_sent_and_the_answer_is_auth() { // The rest of a batch once the token is gone: the message invites to activate. let dir = TestDir::new("no_token"); let mut server = mockito::Server::new_async().await; let any_request = server .mock("GET", mockito::Matcher::Any) .expect(0) .create_async() .await; let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await; assert_eq!(outcome(&result), "auth"); any_request.assert_async().await; } }