- Add fetch_price command with PriceResponse and FetchPriceError types - Privacy-strict header policy (Authorization, Accept, User-Agent only) - Rename SIMPL_API_URL -> MAXIMUS_API_URL across src-tauri - 7+ mockito tests covering happy path, 401/403/404/429/5xx, and header allowlist - Fix pre-existing clippy warnings (doc_overindented_list_items, is_multiple_of) Closes #155
532 lines
21 KiB
Rust
532 lines
21 KiB
Rust
//! 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 use `rusqlite::Connection::open(app_data_dir / db_filename)`,
|
||
//! matching the existing `repair_migrations` helper in `profile_commands.rs`.
|
||
//! - 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 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
|
||
/// `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<AccountReturn, String> {
|
||
let start_date = parse_iso_date(&period_start, "period_start")?;
|
||
let end_date = parse_iso_date(&period_end, "period_end")?;
|
||
|
||
let app_dir = app
|
||
.path()
|
||
.app_data_dir()
|
||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||
let db_path = app_dir.join(&db_filename);
|
||
if !db_path.exists() {
|
||
return Err(format!(
|
||
"Profile database not found: {}",
|
||
db_path.display()
|
||
));
|
||
}
|
||
|
||
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, String> {
|
||
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<Option<f64>, 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<Vec<(NaiveDate, f64)>, 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.
|
||
#[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");
|
||
}
|
||
}
|