3f60efdc33
- TCMB (döviz alış kuru) ve xaus.com (altın) saatlik arka planda çekilir, gün başına saklanır - Kaynak erişilemezse son kur geçerli; altın için son USD kuru kullanılır - Elle kur girişi/silme; aynı gün elle girilen kur önceliklidir - GET /portfolio: güncel değer, kâr/zarar, toplamlar, son kur çekimi - RATES_REFRESH_MINUTES ayarı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
9.8 KiB
Rust
288 lines
9.8 KiB
Rust
//! 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#"<?xml version="1.0" encoding="UTF-8"?>
|
||
<Tarih_Date Tarih="12.11.2026" Date="11/12/2026" Bulten_No="2026/213">
|
||
<Currency CrossOrder="0" Kod="USD" CurrencyCode="USD"><Unit>1</Unit><ForexBuying>50.1691</ForexBuying><ForexSelling>50.2595</ForexSelling></Currency>
|
||
<Currency CrossOrder="9" Kod="JPY" CurrencyCode="JPY"><Unit>100</Unit><ForexBuying>32.4512</ForexBuying></Currency>
|
||
<Currency CrossOrder="19" Kod="XDR" CurrencyCode="XDR"><Unit>1</Unit><ForexBuying>68.5521</ForexBuying><ForexSelling/></Currency>
|
||
</Tarih_Date>"#;
|
||
|
||
/// Sahte kaynak sunucusu: `/tcmb`, `/gold`. Bayraklarla hata verdirilebilir.
|
||
struct Mock {
|
||
src: Sources,
|
||
tcmb_down: Arc<AtomicBool>,
|
||
gold_price: Arc<std::sync::Mutex<String>>,
|
||
}
|
||
|
||
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::<Document>("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::<Document>("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() = "<html>bakım</html>".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;
|
||
}
|