//! Birikimler: varsayılan varlıklar, döviz ekleme, alım/satım, ortalama maliyet, //! eldekinden fazla satış kuralı (geriye tarihli kayıt, düzenleme, silme), migration v2. mod common; use axum::http::StatusCode; use common::{Session, TestApp}; use mongodb::bson::{Document, Uuid, doc}; use serde_json::{Value, json}; const GR: i64 = 1_000_000; async fn assets(app: &TestApp, s: &Session) -> Vec { let (st, b) = app.get("/assets", s).await; assert_eq!(st, StatusCode::OK, "{b}"); b.as_array().unwrap().clone() } async fn asset(app: &TestApp, s: &Session, code: &str) -> Value { assets(app, s) .await .into_iter() .find(|a| a["code"] == code) .unwrap_or_else(|| panic!("{code} yok")) } async fn tx(app: &TestApp, s: &Session, asset_id: &str, body: Value) -> (StatusCode, Value) { app.post(&format!("/assets/{asset_id}/transactions"), s, body) .await } fn id(v: &Value) -> String { v["id"].as_str().unwrap().to_string() } #[tokio::test] async fn default_assets_and_custom_fx() { let app = TestApp::new().await; let s = app.register("Test", None).await; let list = assets(&app, &s).await; let codes: Vec<&str> = list.iter().map(|a| a["code"].as_str().unwrap()).collect(); assert_eq!( codes, [ "TRY", "XAU_GRAM", "XAU_CEYREK", "XAU_YARIM", "XAU_TAM", "XAU_ONS", "USD", "EUR" ] ); assert_eq!(list[0]["kind"], "cash"); assert_eq!(list[1]["unit_label"], "gr"); assert_eq!(list[1]["quantity_e6"], 0); assert_eq!(list[1]["avg_cost_e6"], Value::Null); let (st, gbp) = app .post( "/assets", &s, json!({ "code": " gbp ", "name": "İngiliz Sterlini", "unit_label": "£" }), ) .await; assert_eq!(st, StatusCode::CREATED, "{gbp}"); assert_eq!(gbp["code"], "GBP"); assert_eq!(gbp["kind"], "fx"); let (st, _) = app .post("/assets", &s, json!({ "code": "GBP", "name": "Tekrar" })) .await; assert_eq!(st, StatusCode::CONFLICT); let (st, _) = app .post("/assets", &s, json!({ "code": "GB1", "name": "Hatalı" })) .await; assert_eq!(st, StatusCode::BAD_REQUEST); assert_eq!( assets(&app, &s).await.last().unwrap()["code"], "GBP", "sona eklenir" ); // Başka hane: görmez, işlem ekleyemez let other = app.register("Diger", None).await; assert_eq!(assets(&app, &other).await.len(), 8); let (st, _) = tx( &app, &other, &id(&gbp), json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "total": 5_000 }), ) .await; assert_eq!(st, StatusCode::NOT_FOUND); let (st, _) = app .get(&format!("/assets/{}/transactions", id(&gbp)), &other) .await; assert_eq!(st, StatusCode::NOT_FOUND); app.cleanup().await; } #[tokio::test] async fn weighted_average_cost_and_oversell() { let app = TestApp::new().await; let s = app.register("Test", None).await; let gram = id(&asset(&app, &s, "XAU_GRAM").await); // 10 gr 30.000 TL (birim fiyat hesaplanır), 10 gr 40.000 TL, 5 gr 22.500 TL'ye satış let (st, b1) = tx( &app, &s, &gram, json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 10 * GR, "total": 3_000_000 }), ) .await; assert_eq!(st, StatusCode::CREATED, "{b1}"); assert_eq!(b1["unit_price_e6"], 3_000_000_000_i64); let (st, b2) = tx(&app, &s, &gram, json!({ "type": "buy", "date": "2026-11-05", "quantity_e6": 10 * GR, "unit_price_e6": 3_990_000_000_i64, "total": 4_000_000, "note": " komisyon dahil " })).await; assert_eq!(st, StatusCode::CREATED, "{b2}"); assert_eq!( b2["unit_price_e6"], 3_990_000_000_i64, "girilen birim fiyat korunur" ); assert_eq!(b2["note"], "komisyon dahil"); let (st, _) = tx( &app, &s, &gram, json!({ "type": "sell", "date": "2026-11-10", "quantity_e6": 5 * GR, "total": 2_250_000 }), ) .await; assert_eq!(st, StatusCode::CREATED); let a = asset(&app, &s, "XAU_GRAM").await; assert_eq!(a["quantity_e6"], 15 * GR); assert_eq!( a["cost"], 5_250_000, "maliyet tutardan (birim fiyattan değil)" ); assert_eq!(a["avg_cost_e6"], 3_500_000_000_i64); assert_eq!(a["realized_pl"], 500_000); assert_eq!(a["tx_count"], 3); // Eldekinden fazla satış let (st, b) = tx( &app, &s, &gram, json!({ "type": "sell", "date": "2026-11-12", "quantity_e6": 16 * GR, "total": 1 }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); assert_eq!( b["error"]["message"], "Satış miktarı o tarihte eldeki miktarı aşıyor", "{b}" ); // Geriye tarihli: ilk alımdan önce satış let (st, _) = tx( &app, &s, &gram, json!({ "type": "sell", "date": "2026-10-31", "quantity_e6": GR, "total": 300_000 }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); // Aynı gün: alımdan sonra eklenen satış geçerli (15 gr elde) let (st, _) = tx( &app, &s, &gram, json!({ "type": "sell", "date": "2026-11-01", "quantity_e6": 10 * GR, "total": 3_100_000 }), ) .await; assert_eq!(st, StatusCode::CREATED); // Hatalı girişler for body in [ json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 0, "total": 100 }), json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "total": 0 }), json!({ "type": "buy", "date": "2026-02-30", "quantity_e6": GR, "total": 100 }), json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": GR, "unit_price_e6": -1, "total": 100 }), ] { let (st, b) = tx(&app, &s, &gram, body.clone()).await; assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}"); } // TL: miktar = tutar, birim fiyat 1 let try_id = id(&asset(&app, &s, "TRY").await); let (st, t) = tx( &app, &s, &try_id, json!({ "type": "buy", "date": "2026-11-30", "quantity_e6": 999, "total": 250_050 }), ) .await; assert_eq!(st, StatusCode::CREATED, "{t}"); assert_eq!(t["quantity_e6"], 2_500_500_000_i64, "2.500,50 TL"); assert_eq!(t["unit_price_e6"], GR); let a = asset(&app, &s, "TRY").await; assert_eq!(a["cost"], 250_050); assert_eq!(a["avg_cost_e6"], GR); app.cleanup().await; } #[tokio::test] async fn edits_and_deletes_keep_history_consistent() { let app = TestApp::new().await; let s = app.register("Test", None).await; let usd = id(&asset(&app, &s, "USD").await); let (_, b1) = tx( &app, &s, &usd, json!({ "type": "buy", "date": "2026-11-01", "quantity_e6": 100 * GR, "total": 4_100_000 }), ) .await; let (_, b2) = tx( &app, &s, &usd, json!({ "type": "buy", "date": "2026-11-03", "quantity_e6": 100 * GR, "total": 4_200_000 }), ) .await; let (_, sell) = tx(&app, &s, &usd, json!({ "type": "sell", "date": "2026-11-05", "quantity_e6": 150 * GR, "total": 6_400_000 })).await; // Geçmiş: en yeni başta let (_, list) = app.get(&format!("/assets/{usd}/transactions"), &s).await; let dates: Vec<&str> = list .as_array() .unwrap() .iter() .map(|t| t["date"].as_str().unwrap()) .collect(); assert_eq!(dates, ["2026-11-05", "2026-11-03", "2026-11-01"]); // Alımlardan biri silinirse satış eksiye düşer → reddedilir let (st, b) = app .delete(&format!("/asset-transactions/{}", id(&b1)), &s) .await; assert_eq!(st, StatusCode::CONFLICT, "{b}"); // Satışı büyütmek → eksi let (st, _) = app .patch( &format!("/asset-transactions/{}", id(&sell)), &s, json!({ "quantity_e6": 250 * GR }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); // 2. alımı satıştan sonraya taşımak → satış anında yalnızca 100 $ var let (st, _) = app .patch( &format!("/asset-transactions/{}", id(&b2)), &s, json!({ "date": "2026-11-06" }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); // Sürüm çakışması let (st, _) = app .patch( &format!("/asset-transactions/{}", id(&b2)), &s, json!({ "note": "x", "version": 99 }), ) .await; assert_eq!(st, StatusCode::CONFLICT); // Geçerli düzenleme: satış 100 $ → sonra 2. alım silinebilir let (st, e) = app .patch( &format!("/asset-transactions/{}", id(&sell)), &s, json!({ "quantity_e6": 100 * GR, "total": 4_300_000, "version": 1 }), ) .await; assert_eq!(st, StatusCode::OK, "{e}"); assert_eq!( e["unit_price_e6"], 430_000_000, "43.000 TL / 100 → 430 TL, birim fiyat yeniden hesaplandı" ); assert_eq!(e["version"], 2); let (st, _) = app .delete(&format!("/asset-transactions/{}", id(&b2)), &s) .await; assert_eq!(st, StatusCode::NO_CONTENT); let a = asset(&app, &s, "USD").await; assert_eq!(a["quantity_e6"], 0); assert_eq!(a["cost"], 0); assert_eq!(a["realized_pl"], 200_000, "43.000 − 41.000"); assert_eq!(a["tx_count"], 2, "silinen sayılmaz"); // Pasif varlığa işlem eklenmez, ama listede kalır let (st, _) = app .patch(&format!("/assets/{usd}"), &s, json!({ "is_active": false })) .await; assert_eq!(st, StatusCode::OK); let (st, _) = tx( &app, &s, &usd, json!({ "type": "buy", "date": "2026-11-07", "quantity_e6": GR, "total": 4_300 }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); assert_eq!(asset(&app, &s, "USD").await["is_active"], false); app.cleanup().await; } #[tokio::test] async fn migration_adds_defaults_to_existing_households_once() { let app = TestApp::new().await; let s = app.register("Test", None).await; let hid = Uuid::parse_str(&s.household_id).unwrap(); // Faz 8 öncesi hane: varlık yok, v2 uygulanmamış app.db .collection::("assets") .delete_many(doc! { "household_id": hid }) .await .unwrap(); app.db .collection::("schema_migrations") .delete_one(doc! { "_id": 2 }) .await .unwrap(); assert!(assets(&app, &s).await.is_empty()); kakeibo::db::migrations::run(&app.db).await.unwrap(); assert_eq!(assets(&app, &s).await.len(), 8); // Tekrar çalışsa da çift kayıt yok app.db .collection::("schema_migrations") .delete_one(doc! { "_id": 2 }) .await .unwrap(); kakeibo::db::migrations::run(&app.db).await.unwrap(); assert_eq!(assets(&app, &s).await.len(), 8); app.cleanup().await; }