//! Kurlar: kaynaklardan çekme (sahte sunucuyla, internet yok), gün başına tek kayıt, //! kaynak düşünce son kur, manuel kur önceliği, portföy değerlemesi ve hane izolasyonu. mod common; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; use axum::{Router, http::StatusCode, routing::get}; use common::{Session, TestApp}; use kakeibo::services::{ calendar::{format_date, today_tr}, rates::{self, Sources}, }; use mongodb::bson::{Document, doc}; use serde_json::{Value, json}; const TCMB: &str = r#" 150.169150.2595 10032.4512 168.5521 "#; /// Sahte kaynak sunucusu: `/tcmb`, `/gold`. Bayraklarla hata verdirilebilir. struct Mock { src: Sources, tcmb_down: Arc, gold_price: Arc>, } async fn mock() -> Mock { let tcmb_down = Arc::new(AtomicBool::new(false)); let gold_price = Arc::new(std::sync::Mutex::new( r#"{"per_gram_usd":124.5552}"#.to_string(), )); let (d, g) = (tcmb_down.clone(), gold_price.clone()); let app = Router::new() .route( "/tcmb", get(move || { let d = d.clone(); async move { if d.load(Ordering::SeqCst) { (StatusCode::SERVICE_UNAVAILABLE, String::new()) } else { (StatusCode::OK, TCMB.to_string()) } } }), ) .route( "/gold", get(move || { let g = g.clone(); async move { g.lock().unwrap().clone() } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); Mock { src: Sources { tcmb_url: format!("http://{addr}/tcmb"), gold_url: format!("http://{addr}/gold"), }, tcmb_down, gold_price, } } async fn auto_rate(app: &TestApp, code: &str) -> Document { app.db .collection::("exchange_rates") .find_one(doc! { "household_id": null, "code": code }) .await .unwrap() .unwrap_or_else(|| panic!("{code} kuru yok")) } async fn count(app: &TestApp, filter: Document) -> u64 { app.db .collection::("exchange_rates") .count_documents(filter) .await .unwrap() } #[tokio::test] async fn refresh_writes_one_rate_per_day_and_survives_outages() { let app = TestApp::new().await; let m = mock().await; let client = rates::http_client(); let r = rates::refresh(&app.db, &client, &m.src).await.unwrap(); assert_eq!(r.fx, Some(Ok(3))); assert_eq!(r.gold, Some(Ok(5))); let usd = auto_rate(&app, "USD").await; assert_eq!(usd.get_i64("rate_e6").unwrap(), 50_169_100, "alış kuru"); assert_eq!( usd.get_str("rate_date").unwrap(), "2026-11-12", "bülten tarihi" ); assert_eq!(usd.get_str("source").unwrap(), "tcmb"); assert_eq!( auto_rate(&app, "JPY").await.get_i64("rate_e6").unwrap(), 324_512, "1 yen" ); let gram = auto_rate(&app, "XAU_GRAM").await; assert_eq!(gram.get_i64("rate_e6").unwrap(), 6_248_822_284); assert_eq!(gram.get_str("rate_date").unwrap(), format_date(today_tr())); assert_eq!(gram.get_str("source").unwrap(), "xaus"); assert_eq!(count(&app, doc! {}).await, 8); // Aynı gün yeniden: kayıt sayısı değişmez rates::refresh(&app.db, &client, &m.src).await.unwrap(); assert_eq!(count(&app, doc! {}).await, 8); // TCMB düştü, altın değişti → altın veritabanındaki son USD ile hesaplanır m.tcmb_down.store(true, Ordering::SeqCst); *m.gold_price.lock().unwrap() = r#"{"per_gram_usd":130.0}"#.into(); let r = rates::refresh(&app.db, &client, &m.src).await.unwrap(); assert!(matches!(r.fx, Some(Err(_))), "{:?}", r.fx); assert_eq!(r.gold, Some(Ok(5))); assert_eq!( auto_rate(&app, "XAU_GRAM") .await .get_i64("rate_e6") .unwrap(), 6_521_983_000 ); assert_eq!( auto_rate(&app, "USD").await.get_i64("rate_e6").unwrap(), 50_169_100, "son kur kalır" ); // Bozuk altın yanıtı: hata raporlanır, eski kur kalır *m.gold_price.lock().unwrap() = "bakım".into(); let r = rates::refresh(&app.db, &client, &m.src).await.unwrap(); assert!(matches!(r.gold, Some(Err(_)))); assert_eq!( auto_rate(&app, "XAU_GRAM") .await .get_i64("rate_e6") .unwrap(), 6_521_983_000 ); app.cleanup().await; } async fn asset_id(app: &TestApp, s: &Session, code: &str) -> String { let (_, list) = app.get("/assets", s).await; list.as_array() .unwrap() .iter() .find(|a| a["code"] == code) .unwrap()["id"] .as_str() .unwrap() .to_string() } async fn buy(app: &TestApp, s: &Session, code: &str, quantity_e6: i64, total: i64) { let id = asset_id(app, s, code).await; let (st, b) = app .post( &format!("/assets/{id}/transactions"), s, json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": quantity_e6, "total": total }), ) .await; assert_eq!(st, StatusCode::CREATED, "{b}"); } fn item<'a>(p: &'a Value, code: &str) -> &'a Value { p["items"] .as_array() .unwrap() .iter() .find(|i| i["asset"]["code"] == code) .unwrap() } #[tokio::test] async fn portfolio_values_and_manual_rates() { let app = TestApp::new().await; let m = mock().await; rates::refresh(&app.db, &rates::http_client(), &m.src) .await .unwrap(); let s = app.register("Test", None).await; buy(&app, &s, "XAU_GRAM", 10_000_000, 6_000_000).await; // 10 gr, 60.000 TL buy(&app, &s, "USD", 100_000_000, 450_000).await; // 100 $, 4.500 TL buy(&app, &s, "TRY", 0, 100_000).await; // 1.000 TL buy(&app, &s, "EUR", 10_000_000, 50_000).await; // kuru yok let (st, p) = app.get("/portfolio", &s).await; assert_eq!(st, StatusCode::OK, "{p}"); let g = item(&p, "XAU_GRAM"); assert_eq!(g["value"], 6_248_822, "10 × 6.248,82 TL"); assert_eq!(g["unrealized_pl"], 248_822); assert_eq!(g["rate"]["source"], "xaus"); assert_eq!(item(&p, "USD")["value"], 501_691); assert_eq!(item(&p, "TRY")["value"], 100_000); assert_eq!(item(&p, "TRY")["rate"], Value::Null); assert_eq!(item(&p, "EUR")["value"], Value::Null); assert_eq!(p["cost_total"], 6_600_000); assert_eq!(p["value_total"], 6_850_513); assert_eq!(p["unrealized_pl_total"], 300_513); assert_eq!(p["unpriced"], 1, "EUR elde ama kuru yok"); assert!(p["last_fetch"].is_string()); // Manuel, otomatikle aynı gün → manuel geçerli let (st, man) = app .post( "/rates", &s, json!({ "code": "xau_gram", "rate_e6": 6_500_000_000_i64 }), ) .await; assert_eq!(st, StatusCode::CREATED, "{man}"); assert_eq!(man["source"], "manual"); let (_, p) = app.get("/portfolio", &s).await; assert_eq!(item(&p, "XAU_GRAM")["value"], 6_500_000); assert_eq!(item(&p, "XAU_GRAM")["rate"]["is_manual"], true); // Eski tarihli manuel, daha yeni otomatiğin önüne geçmez app.post( "/rates", &s, json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-11-01" }), ) .await; let (_, p) = app.get("/portfolio", &s).await; assert_eq!(item(&p, "USD")["value"], 501_691); // Daha yeni manuel geçerli; aynı gün tekrar → güncellenir (çift kayıt yok) app.post( "/rates", &s, json!({ "code": "USD", "rate_e6": 60_000_000, "rate_date": "2026-12-31" }), ) .await; app.post( "/rates", &s, json!({ "code": "USD", "rate_e6": 61_000_000, "rate_date": "2026-12-31" }), ) .await; let (_, p) = app.get("/portfolio", &s).await; assert_eq!(item(&p, "USD")["value"], 610_000); assert_eq!( count(&app, doc! { "code": "USD", "rate_date": "2026-12-31" }).await, 1 ); // Başka hane: manuel kurları görmez, silemez let other = app.register("Diger", None).await; let (_, op) = app.get("/portfolio", &other).await; assert_eq!(item(&op, "USD")["rate"]["rate_e6"], 50_169_100); assert_eq!(item(&op, "XAU_GRAM")["rate"]["source"], "xaus"); let man_id = man["id"].as_str().unwrap(); let (st, _) = app.delete(&format!("/rates/{man_id}"), &other).await; assert_eq!(st, StatusCode::NOT_FOUND); // Manuel silinince otomatiğe dönülür let (st, _) = app.delete(&format!("/rates/{man_id}"), &s).await; assert_eq!(st, StatusCode::NO_CONTENT); let (_, p) = app.get("/portfolio", &s).await; assert_eq!(item(&p, "XAU_GRAM")["value"], 6_248_822); // Hatalı girişler for body in [ json!({ "code": "TRY", "rate_e6": 1_000_000 }), json!({ "code": "XYZ", "rate_e6": 1_000_000 }), json!({ "code": "USD", "rate_e6": 0 }), json!({ "code": "USD", "rate_e6": 1, "rate_date": "2026-02-30" }), ] { let (st, b) = app.post("/rates", &s, body.clone()).await; assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}"); } app.cleanup().await; }