Files
suphicakir 7143613ab2 feat(defter): özet ve bütçeye gerçekleşen harcama eklendi (backend)
- Dönem özeti: spent_total ve remaining (harcanabilir − harcanan)
- Bütçe görünümü: grup ve hafta bazında spent
- Değerler saklanmaz, kayıtlardan anlık toplanır; silinenler sayılmaz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:12:51 +03:00

575 lines
18 KiB
Rust
Raw Permalink 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.
//! 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::<Document>("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<i64>) {
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;
}