Merge pull request 'feat(prices): Rust Tauri command fetch_price + tests (#155)' (#165) from issue-155-rust-fetch-price into main
This commit is contained in:
commit
edd1a5cbe4
8 changed files with 469 additions and 25 deletions
8
decisions-log.md
Normal file
8
decisions-log.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
## Issue #155 — fetch_price token loading refactor (MEDIUM)
|
||||
The existing `license_commands.rs` token loading lives in `activation_path()` + `fs::read_to_string()` spread across the file — no single public helper reads the activation token. To keep tests clean (avoid touching the file system), the implementation extracts a private `fetch_price_with_token(token, symbol, date)` function and a thin public `fetch_price` wrapper that reads the activation token from disk and delegates. Tests call the inner function directly, injecting a fake token string. This avoids the need for a temp file setup in every test, keeps the coupling minimal, and matches the existing pattern used in `validate_with_key` (pure inner fn + thin Tauri command wrapper).
|
||||
|
||||
## Issue #155 — FetchPriceError serialization format (MEDIUM)
|
||||
The `Result<PriceResponse, String>` boundary requires serializing `FetchPriceError` to a `String`. Chosen: `serde_json::to_string(&err).unwrap_or("{\"code\":\"internal\"}".to_string())`. This gives the JS layer a stable JSON string it can `JSON.parse()` to read the `code` field and the optional `retry_after_s`. The serde tag `#[serde(tag = "code", rename_all = "snake_case")]` produces `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}` etc. — clean and consistent with the spec's `error.code` shape.
|
||||
|
||||
## Issue #155 — MAXIMUS_API_URL env var in tests (MEDIUM)
|
||||
`std::env::set_var` is deprecated as unsafe in Rust 1.81+ due to multi-threading concerns. However, the tests use `#[tokio::test]` (each runs in isolation) and the set_var call happens before any HTTP client is built, so the race window is practically zero. The alternative `mockito::Server::new_async()` returns a URL we need to inject; the simplest approach is env var override. We use `unsafe { std::env::set_var(...) }` with an explicit comment explaining the safety rationale. This matches common practice in Rust integration tests with mockito.
|
||||
88
src-tauri/Cargo.lock
generated
88
src-tauri/Cargo.lock
generated
|
|
@ -121,6 +121,16 @@ dependencies = [
|
|||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
|
|
@ -573,6 +583,15 @@ dependencies = [
|
|||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
|
|
@ -1970,6 +1989,12 @@ version = "1.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
|
|
@ -1984,6 +2009,7 @@ dependencies = [
|
|||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
|
|
@ -2622,6 +2648,31 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.4",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
|
|
@ -3644,6 +3695,16 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
|
|
@ -3664,6 +3725,16 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
|
|
@ -3682,6 +3753,15 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
|
|
@ -4421,6 +4501,12 @@ version = "0.3.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "simpl-result"
|
||||
version = "0.8.4"
|
||||
|
|
@ -4437,6 +4523,7 @@ dependencies = [
|
|||
"keyring",
|
||||
"libsqlite3-sys",
|
||||
"machine-uid",
|
||||
"mockito",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
|
|
@ -5518,6 +5605,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
|
|
|
|||
|
|
@ -64,3 +64,5 @@ hmac = "0.12"
|
|||
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
|
||||
# for Ed25519.
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
||||
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
|
||||
mockito = "1.6"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142.
|
||||
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155.
|
||||
//!
|
||||
//! At Issue #142 the only command exposed is `compute_account_return`. The
|
||||
//! Modified Dietz formula needs to read snapshot endpoints + linked transfer
|
||||
//! amounts in a single Rust pass (the math itself lives in
|
||||
//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from
|
||||
//! the renderer and keeps the calculation reproducible.
|
||||
//!
|
||||
//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5.
|
||||
//! 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 use `rusqlite::Connection::open(app_data_dir / db_filename)`,
|
||||
|
|
@ -21,10 +21,187 @@
|
|||
|
||||
use chrono::NaiveDate;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
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<String>,
|
||||
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,
|
||||
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::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 stored activation token from disk (raw JWT string).
|
||||
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
|
||||
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|_| FetchPriceError::Auth)?;
|
||||
let token_path = app_dir.join("activation.token");
|
||||
std::fs::read_to_string(&token_path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|_| FetchPriceError::Auth)
|
||||
}
|
||||
|
||||
/// Core implementation — separated from the Tauri command so tests can inject
|
||||
/// an arbitrary token string and base URL without touching the file system.
|
||||
///
|
||||
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
|
||||
/// command is a thin wrapper that loads the token then delegates here. Tests
|
||||
/// call this inner function directly with an explicit `api_base` to avoid
|
||||
/// env-var races between concurrent test threads.
|
||||
async fn fetch_price_with_token(
|
||||
token: &str,
|
||||
symbol: &str,
|
||||
date: &str,
|
||||
) -> Result<PriceResponse, FetchPriceError> {
|
||||
fetch_price_inner(token, symbol, date, &base_url()).await
|
||||
}
|
||||
|
||||
async fn fetch_price_inner(
|
||||
token: &str,
|
||||
symbol: &str,
|
||||
date: &str,
|
||||
api_base: &str,
|
||||
) -> Result<PriceResponse, FetchPriceError> {
|
||||
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 => 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`. 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<PriceResponse, String> {
|
||||
let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
|
||||
fetch_price_with_token(&token, &symbol, &date)
|
||||
.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
|
||||
|
|
@ -181,3 +358,175 @@ fn read_cash_flows(
|
|||
}
|
||||
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.
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,9 +295,9 @@ fn machine_id_internal() -> Result<String, String> {
|
|||
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
||||
}
|
||||
|
||||
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
|
||||
// License server API base URL. Overridable via MAXIMUS_API_URL env var for development.
|
||||
fn api_base_url() -> String {
|
||||
std::env::var("SIMPL_API_URL")
|
||||
std::env::var("MAXIMUS_API_URL")
|
||||
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ fn hex_encode(bytes: &[u8]) -> String {
|
|||
}
|
||||
|
||||
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
||||
if hex.len() % 2 != 0 {
|
||||
if !hex.len().is_multiple_of(2) {
|
||||
return Err("Invalid hex string length".to_string());
|
||||
}
|
||||
(0..hex.len())
|
||||
|
|
|
|||
|
|
@ -23,20 +23,16 @@
|
|||
//! render an explicit warning instead of an opaque empty value):
|
||||
//! - `value_start == None` → `is_partial = true`, `return_pct = None`
|
||||
//! - `value_end == None` → `is_partial = true`, `return_pct = None`
|
||||
//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
|
||||
//! return collapses to the simple
|
||||
//! `(V_end - V_start) / V_start`
|
||||
//! - `period_start == period_end` → no annualization (stays = return_pct)
|
||||
//! - V_start = 0 and first flow > 0 → account created mid-period; the
|
||||
//! denominator is `0 + W_first * CF_first`,
|
||||
//! which is positive as long as the
|
||||
//! flow lands strictly before period_end
|
||||
//! - account depleted then refilled → mathematically defined; the
|
||||
//! function does not panic but the
|
||||
//! magnitude can look extreme — that is
|
||||
//! the inherent Modified Dietz behaviour
|
||||
//! on accounts with near-zero invested
|
||||
//! capital.
|
||||
//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
|
||||
//! return collapses to the simple `(V_end - V_start) / V_start`
|
||||
//! - `period_start == period_end` → no annualization (stays = return_pct)
|
||||
//! - V_start = 0 and first flow > 0 → account created mid-period; the
|
||||
//! denominator is `0 + W_first * CF_first`, which is positive as long as
|
||||
//! the flow lands strictly before period_end
|
||||
//! - account depleted then refilled → mathematically defined; the function
|
||||
//! does not panic but the magnitude can look extreme — that is the
|
||||
//! inherent Modified Dietz behaviour on accounts with near-zero invested
|
||||
//! capital.
|
||||
//!
|
||||
//! Module is **private to the crate** (`pub(crate)`) and lives under
|
||||
//! `commands/` per the spec — reused only by `balance_commands.rs`.
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ pub fn run() {
|
|||
commands::get_file_size,
|
||||
commands::file_exists,
|
||||
commands::compute_account_return,
|
||||
commands::fetch_price,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
Loading…
Reference in a new issue