dc0d783e46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
13 KiB
Rust
395 lines
13 KiB
Rust
//! 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;
|
||
}
|