feat(prices): Rust Tauri command fetch_price + tests (#155) #165
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",
|
"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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -573,6 +583,15 @@ dependencies = [
|
||||||
"inout",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
|
|
@ -1970,6 +1989,12 @@ version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
|
|
@ -1984,6 +2009,7 @@ dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
|
|
@ -2622,6 +2648,31 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
|
|
@ -3644,6 +3695,16 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -3664,6 +3725,16 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -3682,6 +3753,15 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -4421,6 +4501,12 @@ version = "0.3.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
|
|
@ -4437,6 +4523,7 @@ dependencies = [
|
||||||
"keyring",
|
"keyring",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"machine-uid",
|
"machine-uid",
|
||||||
|
"mockito",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
@ -5518,6 +5605,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
|
|
||||||
|
|
@ -64,3 +64,5 @@ hmac = "0.12"
|
||||||
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
|
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
|
||||||
# for Ed25519.
|
# for Ed25519.
|
||||||
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
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
|
//! Commands:
|
||||||
//! Modified Dietz formula needs to read snapshot endpoints + linked transfer
|
//! - `compute_account_return` (Issue #142): Modified Dietz return for one
|
||||||
//! amounts in a single Rust pass (the math itself lives in
|
//! account over a period. Reads snapshot endpoints + linked transfer amounts
|
||||||
//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from
|
//! in a single Rust pass.
|
||||||
//! the renderer and keeps the calculation reproducible.
|
//! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
|
||||||
//!
|
//! a given `(symbol, date)` pair. Privacy-strict: sends only
|
||||||
//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5.
|
//! `Authorization`, `Accept`, and `User-Agent` headers.
|
||||||
//!
|
//!
|
||||||
//! Database access pattern:
|
//! Database access pattern:
|
||||||
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
|
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
|
||||||
|
|
@ -21,10 +21,187 @@
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
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
|
/// Compute the Modified Dietz return for one account over the period
|
||||||
/// `[period_start, period_end]`. Reads:
|
/// `[period_start, period_end]`. Reads:
|
||||||
/// - `value_start`: latest snapshot line for the account whose
|
/// - `value_start`: latest snapshot line for the account whose
|
||||||
|
|
@ -181,3 +358,175 @@ fn read_cash_flows(
|
||||||
}
|
}
|
||||||
Ok(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))
|
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 {
|
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())
|
.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> {
|
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());
|
return Err("Invalid hex string length".to_string());
|
||||||
}
|
}
|
||||||
(0..hex.len())
|
(0..hex.len())
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,14 @@
|
||||||
//! - `value_start == None` → `is_partial = true`, `return_pct = None`
|
//! - `value_start == None` → `is_partial = true`, `return_pct = None`
|
||||||
//! - `value_end == 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`,
|
//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
|
||||||
//! return collapses to the simple
|
//! return collapses to the simple `(V_end - V_start) / V_start`
|
||||||
//! `(V_end - V_start) / V_start`
|
|
||||||
//! - `period_start == period_end` → no annualization (stays = return_pct)
|
//! - `period_start == period_end` → no annualization (stays = return_pct)
|
||||||
//! - V_start = 0 and first flow > 0 → account created mid-period; the
|
//! - V_start = 0 and first flow > 0 → account created mid-period; the
|
||||||
//! denominator is `0 + W_first * CF_first`,
|
//! denominator is `0 + W_first * CF_first`, which is positive as long as
|
||||||
//! which is positive as long as the
|
//! the flow lands strictly before period_end
|
||||||
//! flow lands strictly before period_end
|
//! - account depleted then refilled → mathematically defined; the function
|
||||||
//! - account depleted then refilled → mathematically defined; the
|
//! does not panic but the magnitude can look extreme — that is the
|
||||||
//! function does not panic but the
|
//! inherent Modified Dietz behaviour on accounts with near-zero invested
|
||||||
//! magnitude can look extreme — that is
|
|
||||||
//! the inherent Modified Dietz behaviour
|
|
||||||
//! on accounts with near-zero invested
|
|
||||||
//! capital.
|
//! capital.
|
||||||
//!
|
//!
|
||||||
//! Module is **private to the crate** (`pub(crate)`) and lives under
|
//! Module is **private to the crate** (`pub(crate)`) and lives under
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ pub fn run() {
|
||||||
commands::get_file_size,
|
commands::get_file_size,
|
||||||
commands::file_exists,
|
commands::file_exists,
|
||||||
commands::compute_account_return,
|
commands::compute_account_return,
|
||||||
|
commands::fetch_price,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue