Files
kakeibo/backend/tests/backup.rs
T

389 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
}