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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user