//! Günlük defter: harcama kayıtları. mod common; use axum::http::StatusCode; use common::{Session, TestApp}; use mongodb::bson::{Document, Uuid, doc}; use serde_json::{Value, json}; async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String { let (st, p) = app .post( "/periods", s, json!({ "start_date": start, "end_date": end }), ) .await; assert_eq!(st, StatusCode::CREATED, "{p}"); p["id"].as_str().unwrap().to_string() } /// Hanenin ilk (örnek) kategorisi, verilen grupta async fn category(app: &TestApp, s: &Session, group: &str) -> String { let (_, cats) = app.get("/categories", s).await; cats.as_array() .unwrap() .iter() .find(|c| c["group"] == group) .unwrap_or_else(|| panic!("{group} grubunda kategori yok"))["id"] .as_str() .unwrap() .to_string() } async fn payment_method(app: &TestApp, s: &Session) -> String { let (_, pms) = app.get("/payment-methods", s).await; pms[0]["id"].as_str().unwrap().to_string() } async fn add(app: &TestApp, s: &Session, pid: &str, body: Value) -> (StatusCode, Value) { app.post(&format!("/periods/{pid}/entries"), s, body).await } async fn close(app: &TestApp, pid: &str) { app.db .collection::("periods") .update_one( doc! { "_id": Uuid::parse_str(pid).unwrap() }, doc! { "$set": { "status": "closed" } }, ) .await .unwrap(); } fn ids(list: &Value) -> Vec<&str> { list.as_array() .unwrap() .iter() .map(|e| e["description"].as_str().unwrap_or("")) .collect() } #[tokio::test] async fn crud_group_from_category_and_ordering() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; let needs = category(&app, &s, "needs").await; let wants = category(&app, &s, "wants").await; let pm = payment_method(&app, &s).await; let (st, e) = add( &app, &s, &pid, json!({ "date": "2026-12-09", "amount": 34_550, "category_id": needs, "payment_method_id": pm, "description": " Haftalık market " }), ) .await; assert_eq!(st, StatusCode::CREATED, "{e}"); assert_eq!(e["group"], "needs", "grup kategoriden gelir"); assert_eq!(e["description"], "Haftalık market", "boşluklar kırpılır"); assert_eq!(e["payment_method_id"], pm.as_str()); assert_eq!(e["version"], 1); let id = e["id"].as_str().unwrap().to_string(); // Ödeme türü ve açıklama isteğe bağlı for (date, desc) in [ ("2026-12-09", "aynı gün, sonra"), ("2026-12-02", "erken"), ("2026-12-20", "geç"), ] { let (st, b) = add( &app, &s, &pid, json!({ "date": date, "amount": 1_000, "category_id": wants, "description": desc }), ) .await; assert_eq!(st, StatusCode::CREATED, "{b}"); assert!(b["payment_method_id"].is_null()); } // Sıra: yeni tarih önce; aynı gün içinde son eklenen önce let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await; assert_eq!( ids(&list), ["geç", "aynı gün, sonra", "Haftalık market", "erken"] ); // Tarih aralığı (hafta) filtresi: iki uç dahil let (_, week) = app .get( &format!("/periods/{pid}/entries?from=2026-12-07&to=2026-12-13"), &s, ) .await; assert_eq!(ids(&week), ["aynı gün, sonra", "Haftalık market"]); let (st, _) = app .get(&format!("/periods/{pid}/entries?from=2026-13-01"), &s) .await; assert_eq!(st, StatusCode::BAD_REQUEST); // Düzenle: kategori değişince grup da değişir; boş metin → kaldır let (st, u) = app .patch( &format!("/entries/{id}"), &s, json!({ "amount": 40_000, "category_id": wants, "payment_method_id": "", "description": "", "version": 1 }), ) .await; assert_eq!(st, StatusCode::OK, "{u}"); assert_eq!(u["amount"], 40_000); assert_eq!(u["group"], "wants"); assert!(u["payment_method_id"].is_null()); assert!(u["description"].is_null()); assert_eq!(u["version"], 2); assert_eq!(u["date"], "2026-12-09", "gönderilmeyen alan değişmez"); // Eski sürümle düzenleme → 409 let (st, _) = app .patch( &format!("/entries/{id}"), &s, json!({ "amount": 1, "version": 1 }), ) .await; assert_eq!(st, StatusCode::CONFLICT); // Sil → listede yok let (st, _) = app.delete(&format!("/entries/{id}"), &s).await; assert_eq!(st, StatusCode::NO_CONTENT); let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await; assert_eq!(list.as_array().unwrap().len(), 3); let (st, _) = app .patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 })) .await; assert_eq!(st, StatusCode::NOT_FOUND, "silinmiş kayıt düzenlenemez"); app.cleanup().await; } #[tokio::test] async fn delete_and_restore_undo() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; let needs = category(&app, &s, "needs").await; let (_, e) = add( &app, &s, &pid, json!({ "date": "2026-12-03", "amount": 2_500, "category_id": needs }), ) .await; let id = e["id"].as_str().unwrap(); // Silinmemiş kayıt geri alınamaz let (st, _) = app .post(&format!("/entries/{id}/restore"), &s, json!({})) .await; assert_eq!(st, StatusCode::NOT_FOUND); app.delete(&format!("/entries/{id}"), &s).await; let (st, r) = app .post(&format!("/entries/{id}/restore"), &s, json!({})) .await; assert_eq!(st, StatusCode::OK, "{r}"); assert_eq!(r["amount"], 2_500); assert_eq!(r["version"], 3, "sil + geri al iki değişiklik"); let (_, list) = app.get(&format!("/periods/{pid}/entries"), &s).await; assert_eq!( list.as_array().unwrap().len(), 1, "geri gelen kayıt listede" ); // İkinci kez geri alma → 404 let (st, _) = app .post(&format!("/entries/{id}/restore"), &s, json!({})) .await; assert_eq!(st, StatusCode::NOT_FOUND); app.cleanup().await; } #[tokio::test] async fn validation_errors_are_turkish_400s() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; let needs = category(&app, &s, "needs").await; let base = json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }); let with = |k: &str, v: Value| { let mut b = base.clone(); b[k] = v; b }; let cases = [ (with("amount", json!(0)), "Tutar sıfırdan büyük olmalı"), (with("amount", json!(-5)), "Tutar sıfırdan büyük olmalı"), ( with("date", json!("2027-01-01")), "Tarih dönem içinde olmalı", ), (with("date", json!("05.12.2026")), "Tarih geçersiz"), (with("category_id", json!("")), "Kategori geçersiz"), ( with("category_id", json!(Uuid::new().to_string())), "Kategori bulunamadı", ), ( with("payment_method_id", json!("xyz")), "Ödeme türü geçersiz", ), ( with("payment_method_id", json!(Uuid::new().to_string())), "Ödeme türü bulunamadı", ), ( with("description", json!("a".repeat(201))), "Açıklama en fazla 200 karakter olabilir", ), ]; for (body, msg) in cases { let (st, b) = add(&app, &s, &pid, body.clone()).await; assert_eq!(st, StatusCode::BAD_REQUEST, "{body} → {b}"); assert_eq!(b["error"]["message"], msg, "{body}"); } // 200 karakter (emoji = 1 karakter) kabul let (st, _) = add(&app, &s, &pid, with("description", json!("🛒".repeat(200)))).await; assert_eq!(st, StatusCode::CREATED); // Pasif kategori: yeni kayıtta olmaz, onu zaten kullanan kayıt düzenlenebilir let (_, e) = add(&app, &s, &pid, base.clone()).await; let (st, _) = app .patch( &format!("/categories/{needs}"), &s, json!({ "is_active": false }), ) .await; assert_eq!(st, StatusCode::OK); let (st, b) = add(&app, &s, &pid, base.clone()).await; assert_eq!(st, StatusCode::BAD_REQUEST); assert_eq!( b["error"]["message"], "Bu kategori pasif; başka bir kategori seçin" ); let (st, b) = app .patch( &format!("/entries/{}", e["id"].as_str().unwrap()), &s, json!({ "amount": 2_000, "category_id": needs }), ) .await; assert_eq!( st, StatusCode::OK, "eski kaydın kendi kategorisi kalabilir: {b}" ); app.cleanup().await; } #[tokio::test] async fn closed_period_is_read_only() { let app = TestApp::new().await; let s = app.register("Test", None).await; let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; let needs = category(&app, &s, "needs").await; let (_, e) = add( &app, &s, &pid, json!({ "date": "2026-12-05", "amount": 1_000, "category_id": needs }), ) .await; let (_, gone) = add( &app, &s, &pid, json!({ "date": "2026-12-06", "amount": 500, "category_id": needs }), ) .await; let id = e["id"].as_str().unwrap(); let gone_id = gone["id"].as_str().unwrap(); app.delete(&format!("/entries/{gone_id}"), &s).await; close(&app, &pid).await; let (st, _) = add( &app, &s, &pid, json!({ "date": "2026-12-07", "amount": 1, "category_id": needs }), ) .await; assert_eq!(st, StatusCode::CONFLICT); let (st, _) = app .patch(&format!("/entries/{id}"), &s, json!({ "amount": 5 })) .await; assert_eq!(st, StatusCode::CONFLICT); let (st, _) = app.delete(&format!("/entries/{id}"), &s).await; assert_eq!(st, StatusCode::CONFLICT); let (st, _) = app .post(&format!("/entries/{gone_id}/restore"), &s, json!({})) .await; assert_eq!(st, StatusCode::CONFLICT); // Okuma serbest let (st, list) = app.get(&format!("/periods/{pid}/entries"), &s).await; assert_eq!(st, StatusCode::OK); assert_eq!(list.as_array().unwrap().len(), 1); app.cleanup().await; } #[tokio::test] async fn entries_are_isolated_between_households() { let app = TestApp::new().await; let a = app.register("Ali", None).await; let b = app.register("Banu", None).await; let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await; let b_pid = open_period(&app, &b, "2026-12-01", "2027-01-01").await; let a_cat = category(&app, &a, "needs").await; let a_pm = payment_method(&app, &a).await; let (_, e) = add( &app, &a, &pid, json!({ "date": "2026-12-05", "amount": 1_000, "category_id": a_cat }), ) .await; let id = e["id"].as_str().unwrap(); // B, A'nın dönemini/kaydını göremez, değiştiremez let (st, _) = app.get(&format!("/periods/{pid}/entries"), &b).await; assert_eq!(st, StatusCode::NOT_FOUND); let (st, _) = add( &app, &b, &pid, json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }), ) .await; assert_eq!(st, StatusCode::NOT_FOUND); let (st, _) = app .patch(&format!("/entries/{id}"), &b, json!({ "amount": 5 })) .await; assert_eq!(st, StatusCode::NOT_FOUND); let (st, _) = app.delete(&format!("/entries/{id}"), &b).await; assert_eq!(st, StatusCode::NOT_FOUND); app.delete(&format!("/entries/{id}"), &a).await; let (st, _) = app .post(&format!("/entries/{id}/restore"), &b, json!({})) .await; assert_eq!( st, StatusCode::NOT_FOUND, "başkasının silinmiş kaydı geri alınamaz" ); // B kendi dönemine A'nın kategorisini / ödeme türünü kullanamaz let (st, body) = add( &app, &b, &b_pid, json!({ "date": "2026-12-05", "amount": 1, "category_id": a_cat }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); assert_eq!(body["error"]["message"], "Kategori bulunamadı"); let b_cat = category(&app, &b, "needs").await; let (st, body) = add( &app, &b, &b_pid, json!({ "date": "2026-12-05", "amount": 1, "category_id": b_cat, "payment_method_id": a_pm }), ) .await; assert_eq!(st, StatusCode::BAD_REQUEST); assert_eq!(body["error"]["message"], "Ödeme türü bulunamadı"); app.cleanup().await; } /// Bütçe önerisi (önceki dönem) artık API'den girilen gerçek kayıtları kullanır. #[tokio::test] async fn previous_suggestion_uses_real_entries() { let app = TestApp::new().await; let s = app.register("Test", None).await; let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await; let needs = category(&app, &s, "needs").await; let culture = category(&app, &s, "culture").await; for (cat, amount) in [(&needs, 120_000), (&needs, 30_000), (&culture, 15_000)] { let (st, b) = add( &app, &s, &nov, json!({ "date": "2026-11-10", "amount": amount, "category_id": cat }), ) .await; assert_eq!(st, StatusCode::CREATED, "{b}"); } // Silinen kayıt toplama girmez let (_, del) = add( &app, &s, &nov, json!({ "date": "2026-11-11", "amount": 999_999, "category_id": needs }), ) .await; app.delete(&format!("/entries/{}", del["id"].as_str().unwrap()), &s) .await; close(&app, &nov).await; let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await; let (_, sug) = app .get( &format!("/periods/{dec}/budget/suggestion?method=previous"), &s, ) .await; assert_eq!(sug["available"], true, "{sug}"); assert_eq!( sug["amounts"], json!({ "needs": 150_000, "wants": 0, "culture": 15_000, "unexpected": 0 }) ); app.cleanup().await; } fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec) { let g = budget .as_array() .unwrap() .iter() .find(|g| g["group"] == group) .unwrap(); let weeks = g["weeks"] .as_array() .unwrap() .iter() .map(|w| w["spent"].as_i64().unwrap()) .collect(); (g["spent"].as_i64().unwrap(), weeks) } #[tokio::test] async fn actual_spending_in_summary_and_budget() { let app = TestApp::new().await; let s = app.register("Test", None).await; // Kasım'da bir harcama: Aralık toplamlarına girmemeli let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await; let needs = category(&app, &s, "needs").await; let wants = category(&app, &s, "wants").await; let culture = category(&app, &s, "culture").await; let (st, _) = add( &app, &s, &nov, json!({ "date": "2026-11-30", "amount": 1_000_000, "category_id": needs }), ) .await; assert_eq!(st, StatusCode::CREATED); close(&app, &nov).await; let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; app.post( &format!("/periods/{pid}/plan-items"), &s, json!({ "kind": "income", "name": "Maaş", "amount": 300_000 }), ) .await; // Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 for (date, amount, cat) in [ ("2026-12-01", 1_000, &needs), ("2026-12-06", 2_000, &needs), // 1. haftanın son günü ("2026-12-07", 500, &needs), // 2. haftanın ilk günü ("2026-12-31", 300, &wants), // dönemin son günü ] { let (st, b) = add( &app, &s, &pid, json!({ "date": date, "amount": amount, "category_id": cat }), ) .await; assert_eq!(st, StatusCode::CREATED, "{b}"); } // Kategori değişince harcama diğer gruba geçer let (_, moved) = add( &app, &s, &pid, json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }), ) .await; app.patch( &format!("/entries/{}", moved["id"].as_str().unwrap()), &s, json!({ "category_id": wants }), ) .await; // Silinen harcama sayılmaz let (_, gone) = add( &app, &s, &pid, json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }), ) .await; let gone_id = gone["id"].as_str().unwrap(); app.delete(&format!("/entries/{gone_id}"), &s).await; let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; // 1.000 + 2.000 + 500 + 300 + 400 = 4.200 assert_eq!( (sum["spent_total"].as_i64(), sum["remaining"].as_i64()), (Some(4_200), Some(295_800)), "{sum}" ); let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await; assert_eq!(st, StatusCode::OK); assert_eq!( spent_by_week(&b, "needs"), (3_500, vec![3_000, 500, 0, 0, 0]) ); assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 0, 300])); assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 5])); // Geri al → yeniden sayılır app.post(&format!("/entries/{gone_id}/restore"), &s, json!({})) .await; let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await; assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0, 0])); // Harcanabilir aşılırsa kalan negatif add( &app, &s, &pid, json!({ "date": "2026-12-20", "amount": 300_000, "category_id": needs }), ) .await; let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; assert_eq!(sum["remaining"], 300_000 - 304_900); app.cleanup().await; }