feat(yedek): JSON tam hane yedeği backend (önizleme, geri yükleme, geri dönüş noktaları)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 20:49:13 +03:00
parent 6e1000afd2
commit 0d70576df7
20 changed files with 1768 additions and 2 deletions
+388
View File
@@ -0,0 +1,388 @@
//! 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::<Value>(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<Value> {
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::<Document>("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<_>>(),
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;
}