//! JSON tam hane yedeği: dışa aktarma, önizleme, aynı / başka haneye geri yükleme, //! geri dönüş noktaları ("geri al"), bozuk dosya, yetki. mod common; use axum::http::StatusCode; use common::{Session, TestApp}; use mongodb::bson::{Bson, Document, doc}; use serde_json::{Value, json}; async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String { let (_, list) = app.get(path, s).await; list.as_array() .unwrap() .iter() .find(|c| c["name"] == name) .unwrap_or_else(|| panic!("{name} yok"))["id"] .as_str() .unwrap() .to_string() } fn id(v: &Value) -> String { v["id"].as_str().unwrap().to_string() } /// Ekim (kapalı, birikim TL'ye aktarılmış, devirli) + Kasım (açık, harcama, not, bütçe) async fn build(app: &TestApp, s: &Session) -> (String, String) { let market = id_by(app, s, "/categories", "Market").await; let (_, oct) = app .post( "/periods", s, json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), ) .await; let oct = id(&oct); app.post( &format!("/periods/{oct}/plan-items"), s, json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }), ) .await; for (date, amount) in [("2026-10-05", 200_000), ("2026-10-06", 1)] { let (st, e) = app .post( &format!("/periods/{oct}/entries"), s, json!({ "date": date, "amount": amount, "category_id": market, "description": "Ekim" }), ) .await; assert_eq!(st, StatusCode::CREATED, "{e}"); if amount == 1 { app.delete(&format!("/entries/{}", id(&e)), s).await; // silinmiş kayıt da taşınır } } let (st, b) = app .post( &format!("/periods/{oct}/close"), s, json!({ "to_savings": 500_000 }), ) .await; assert_eq!(st, StatusCode::OK, "{b}"); let (st, b) = app .post(&format!("/periods/{oct}/to-savings"), s, json!({})) .await; assert_eq!(st, StatusCode::CREATED, "{b}"); let (_, nov) = app .post( "/periods", s, json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }), ) .await; let nov = id(&nov); app.put( &format!("/periods/{nov}/budget"), s, json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 }), ) .await; app.post( &format!("/periods/{nov}/entries"), s, json!({ "date": "2026-11-03", "amount": 45_090, "category_id": market, "description": "Migros" }), ) .await; app.post( &format!("/periods/{nov}/notes"), s, json!({ "context": "planning", "title": "Hedef" }), ) .await; (oct, nov) } async fn download(app: &TestApp, s: &Session) -> String { let (st, ct, body) = app.get_text("/backup/download", s).await; assert_eq!(st, StatusCode::OK, "{body}"); assert!(ct.starts_with("application/json"), "{ct}"); body } fn data(file: &str) -> Value { serde_json::from_str::(file).unwrap()["data"].clone() } /// Bir dönemin özet + harcama listesi (karşılaştırma için) async fn view(app: &TestApp, s: &Session, pid: &str) -> (Value, Value) { let (_, sum) = app.get(&format!("/periods/{pid}/summary"), s).await; let (_, list) = app.get(&format!("/periods/{pid}/entries"), s).await; (sum, list) } async fn points(app: &TestApp, s: &Session) -> Vec { let (_, st) = app.get("/backup/status", s).await; st["restore_points"].as_array().unwrap().clone() } #[tokio::test] async fn same_household_restore_and_undo_chain() { let app = TestApp::new().await; let s = app.register("Sahip", None).await; let (oct, nov) = build(&app, &s).await; let before = view(&app, &s, &nov).await; let (_, st) = app.get("/backup/status", &s).await; assert!(st["last_backup_at"].is_null(), "henüz yedek yok"); let file = download(&app, &s).await; let v: Value = serde_json::from_str(&file).unwrap(); assert_eq!(v["format"], "kakeibo-backup"); assert_eq!(v["counts"]["entries"], 2, "silinmiş sayılmaz"); assert_eq!( v["data"]["entries"].as_array().unwrap().len(), 3, "ama dosyada var" ); assert!(v["data"].get("users").is_none() && !file.contains("password")); let (_, st) = app.get("/backup/status", &s).await; assert!(st["last_backup_at"].is_string()); // Yedekten sonra değişiklikler let market = id_by(&app, &s, "/categories", "Market").await; app.post( &format!("/periods/{nov}/entries"), &s, json!({ "date": "2026-11-10", "amount": 999, "category_id": market }), ) .await; app.patch( &format!("/categories/{market}"), &s, json!({ "name": "Market (yeni)" }), ) .await; // Önizleme: hiçbir şey yazmaz let (st, p) = app.post_text("/backup/preview", &s, &file).await; assert_eq!(st, StatusCode::OK, "{p}"); assert_eq!(p["same_household"], true); assert_eq!( (p["first_date"].as_str(), p["last_date"].as_str()), (Some("2026-10-01"), Some("2026-11-30")) ); let entries = p["counts"] .as_array() .unwrap() .iter() .find(|c| c["kind"] == "entries") .unwrap(); assert_eq!( (entries["backup"].as_i64(), entries["current"].as_i64()), (Some(2), Some(3)) ); assert_eq!(p["changed_since"], 2, "{p}"); assert!(p["warnings"][0].as_str().unwrap().contains("2 kayıt")); assert!(points(&app, &s).await.is_empty()); // Geri yükle → yedekteki durum birebir let (st, r) = app.post_text("/backup/restore", &s, &file).await; assert_eq!(st, StatusCode::OK, "{r}"); assert_eq!(view(&app, &s, &nov).await, before); assert_eq!( data(&download(&app, &s).await), data(&file), "kayıtlar birebir" ); let (_, oct_p) = app.get(&format!("/periods/{oct}"), &s).await; assert_eq!(oct_p["status"], "closed"); // Tür korunur: version i32, tutar i64 let e: Document = app .db .collection::("entries") .find_one(doc! { "amount": 45_090_i64 }) .await .unwrap() .unwrap(); assert!(matches!(e.get("version"), Some(Bson::Int32(_)))); assert!(matches!(e.get("amount"), Some(Bson::Int64(_)))); let list = points(&app, &s).await; assert_eq!(list.len(), 1); assert_eq!(list[0]["reason"], "before_restore"); assert_eq!(list[0]["entries"], 3, "geri yüklemeden önceki durum"); assert_eq!(list[0]["id"], r["restore_point_id"]); // Geri al → değişiklikler geri gelir; bu da bir nokta bırakır let (st, u) = app .post( &format!( "/backup/restore-points/{}/restore", r["restore_point_id"].as_str().unwrap() ), &s, json!({}), ) .await; assert_eq!(st, StatusCode::OK, "{u}"); let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await; assert_eq!(list.as_array().unwrap().len(), 2); assert!( app.get("/categories", &s) .await .1 .as_array() .unwrap() .iter() .any(|c| c["name"] == "Market (yeni)") ); let list = points(&app, &s).await; assert_eq!( list.iter().map(|p| p["reason"].clone()).collect::>(), vec![json!("before_undo"), json!("before_restore")] ); // "Geri al"ı geri al → yine yedekteki durum let (st, _) = app .post( &format!( "/backup/restore-points/{}/restore", u["restore_point_id"].as_str().unwrap() ), &s, json!({}), ) .await; assert_eq!(st, StatusCode::OK); assert_eq!(view(&app, &s, &nov).await, before); // En fazla 3 nokta saklanır app.post_text("/backup/restore", &s, &file).await; assert_eq!(points(&app, &s).await.len(), 3); app.cleanup().await; } #[tokio::test] async fn copy_to_another_household_gets_new_ids() { let app = TestApp::new().await; let a = app.register("Kaynak", None).await; let (oct, nov) = build(&app, &a).await; let file = download(&app, &a).await; let a_before = view(&app, &a, &nov).await; let b = app.register("Hedef", None).await; let (st, p) = app.post_text("/backup/preview", &b, &file).await; assert_eq!(st, StatusCode::OK, "{p}"); assert_eq!(p["same_household"], false); assert_eq!(p["source_name"], "Kaynak Hanesi"); assert!( p["warnings"][0] .as_str() .unwrap() .contains("başka bir haneden") ); for round in 0..2 { // İki kez yüklense de kimlik çakışmaz let (st, r) = app.post_text("/backup/restore", &b, &file).await; assert_eq!(st, StatusCode::OK, "tur {round}: {r}"); } let (_, periods) = app.get("/periods", &b).await; let periods = periods.as_array().unwrap(); assert_eq!(periods.len(), 2); assert!( periods .iter() .all(|p| p["id"] != oct.as_str() && p["id"] != nov.as_str()) ); let b_nov = periods.iter().find(|p| p["status"] == "open").unwrap(); let b_nov_id = id(b_nov); // Aynı rakamlar, yeni kimlikler; referanslar bu hanenin kayıtlarına let (sum, list) = view(&app, &b, &b_nov_id).await; assert_eq!(sum, a_before.0); let entry = &list[0]; assert_eq!(entry["amount"], 45_090); assert_ne!(entry["id"], a_before.1[0]["id"]); let (_, cats) = app.get("/categories", &b).await; assert!( cats.as_array() .unwrap() .iter() .any(|c| c["id"] == entry["category_id"]) ); let (_, me) = app.get("/auth/me", &b).await; assert_eq!( entry["created_by"], me["user"]["id"], "kaynak kişi → geri yükleyen" ); let (_, pf) = app.get("/portfolio", &b).await; assert!( pf.to_string().contains("500000"), "birikim aktarımı da taşındı: {pf}" ); // Kaynak hane değişmedi; hedef kaynağın kayıtlarını göremez assert_eq!(view(&app, &a, &nov).await, a_before); let (st, _) = app.get(&format!("/periods/{nov}"), &b).await; assert_eq!(st, StatusCode::NOT_FOUND); app.cleanup().await; } #[tokio::test] async fn bad_files_and_permissions() { let app = TestApp::new().await; let owner = app.register("Sahip", None).await; let (_, nov) = build(&app, &owner).await; let (_, inv) = app.post("/household/invites", &owner, json!({})).await; let member = app .register("Uye", Some(inv["code"].as_str().unwrap())) .await; // Üye yedek alabilir, geri yükleyemez let file = download(&app, &member).await; for path in ["/backup/preview", "/backup/restore"] { let (st, _) = app.post_text(path, &member, &file).await; assert_eq!(st, StatusCode::FORBIDDEN, "{path}"); } // Bozuk / değiştirilmiş / yabancı dosya → anlaşılır hata, hiçbir şey yazılmaz let before = view(&app, &owner, &nov).await; let tampered = file.replacen("45090", "45091", 1); let cases = [ (tampered.as_str(), "bozuk"), (&file[..file.len() / 2], "Kakeibo yedeği değil"), ("Tarih;Tutar\n01.11.2026;5", "Kakeibo yedeği değil"), ]; for (body, msg) in cases { let (st, b) = app.post_text("/backup/restore", &owner, body).await; assert_eq!(st, StatusCode::BAD_REQUEST, "{b}"); assert!(b["error"]["message"].as_str().unwrap().contains(msg), "{b}"); } let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1); let (st, b) = app.post_text("/backup/preview", &owner, &newer).await; assert_eq!(st, StatusCode::BAD_REQUEST); assert!( b["error"]["message"] .as_str() .unwrap() .contains("daha yeni"), "{b}" ); assert_eq!(view(&app, &owner, &nov).await, before); assert!(points(&app, &owner).await.is_empty()); // Geri dönüş noktası: üye göremez; başka hane kullanamaz let (_, r) = app.post_text("/backup/restore", &owner, &file).await; let pid = r["restore_point_id"].as_str().unwrap(); assert!(points(&app, &member).await.is_empty()); let (st, _) = app .post( &format!("/backup/restore-points/{pid}/restore"), &member, json!({}), ) .await; assert_eq!(st, StatusCode::FORBIDDEN); let other = app.register("Diger", None).await; let (st, _) = app .post( &format!("/backup/restore-points/{pid}/restore"), &other, json!({}), ) .await; assert_eq!(st, StatusCode::NOT_FOUND); app.cleanup().await; }