Files
kakeibo/backend/tests/budget.rs
T
suphicakir 95231cd4d6 feat(bütçe): aylık/haftalık bütçe, bütçe önerisi ve dönem özeti (backend)
- /api/periods/{id}/summary: gelir − sabit gider − birikim − devreden borç + artı = harcanabilir
- /api/periods/{id}/budget: 4 grup aylık; haftalar gün sayısına göre, toplam kuruşu kuruşuna eşit
- Elle hafta ayarı (kalan diğer haftalara dağılır), otomatiğe dönüş, aşım uyarısı
- Öneri: varsayılan yüzdeler / önceki dönem / son 3 dönem ortalaması
- Testler: orantılı bölme (3), bütçe entegrasyon (6)

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

404 lines
12 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.
//! Bütçe ve dönem özeti.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn group<'a>(budget: &'a Value, code: &str) -> &'a Value {
budget
.as_array()
.unwrap()
.iter()
.find(|g| g["group"] == code)
.unwrap()
}
fn week_amounts(g: &Value) -> Vec<i64> {
g["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["amount"].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn summary_computes_spendable_with_carryovers() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
// Boş dönem: gelir yok → harcanabilir = -birikim
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["spendable"], -500_000);
add_item(&app, &s, &pid, "income", 4_500_000).await;
add_item(&app, &s, &pid, "income", 300_000).await;
add_item(&app, &s, &pid, "expense", 1_500_000).await;
add_item(&app, &s, &pid, "expense", 45_000).await;
// Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak)
let hid = Uuid::parse_str(&s.household_id).unwrap();
let to = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("carryovers")
.insert_many([
doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null },
doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null },
])
.await
.unwrap();
let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(
sum,
json!({
"income_total": 4_800_000,
"fixed_expense_total": 1_545_000,
"savings_goal": 500_000,
"carryover_debt": 100_000,
"carryover_surplus": 20_000,
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
"spendable": 2_675_000,
"budget_total": 0,
"unallocated": 2_675_000
})
);
app.cleanup().await;
}
#[tokio::test]
async fn default_suggestion_splits_spendable() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}/budget/suggestion?method=default");
// Gelir yokken öneri yok
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], false);
assert!(sug["message"].as_str().unwrap().contains("Harcanabilir"));
add_item(&app, &s, &pid, "income", 1_234_567).await;
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], true);
assert_eq!(
sug["amounts"],
json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 })
);
// Önceki dönem yokken previous/avg3 → veri yok
for m in ["previous", "avg3"] {
let (_, sug) = app
.get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s)
.await;
assert_eq!(sug["available"], false, "{m}");
}
app.cleanup().await;
}
#[tokio::test]
async fn monthly_budget_is_split_into_weeks_by_days() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
add_item(&app, &s, &pid, "income", 300_000).await;
let url = format!("/periods/{pid}/budget");
// Başta hepsi 0
let (_, b) = app.get(&url, &s).await;
assert_eq!(b.as_array().unwrap().len(), 4);
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]);
let (st, b) = app
.put(
&url,
&s,
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(needs["monthly"], 100_000);
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000
assert_eq!(
week_amounts(needs),
vec![19_355, 22_581, 22_581, 22_580, 12_903]
);
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
for g in b.as_array().unwrap() {
assert_eq!(
week_amounts(g).iter().sum::<i64>(),
g["monthly"].as_i64().unwrap()
);
}
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
(Some(150_031), Some(149_969))
);
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
app.put(
&url,
&s,
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["unallocated"], -100_000);
// Negatif tutar → 400
let (st, _) = app
.put(
&url,
&s,
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn manual_week_override_redistributes_the_rest() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let weeks_url = format!("/periods/{pid}/budget/weeks");
// 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün)
let (st, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(
week_amounts(needs),
vec![12_500, 50_000, 14_584, 14_583, 8_333]
);
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
let manual: Vec<bool> = needs["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["is_manual"].as_bool().unwrap())
.collect();
assert_eq!(manual, vec![false, true, false, false, false]);
assert_eq!(needs["weekly_over"], false);
// Aylık tutar değişince elle girilen hafta korunur
let (_, b) = app
.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }),
)
.await;
let needs = group(&b, "needs");
assert_eq!(needs["weekly_over"], true);
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]);
// null → otomatiğe dön
app.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }),
)
.await;
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
assert!(
group(&b, "needs")["weeks"]
.as_array()
.unwrap()
.iter()
.all(|w| w["is_manual"] == false)
);
// Haftanın ilk günü olmayan tarih → 400
let (st, _) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn previous_and_avg3_suggestions_use_actual_spending() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let hid = Uuid::parse_str(&s.household_id).unwrap();
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
let mut closed = Vec::new();
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": start, "end_date": end }),
)
.await;
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
closed.push(id);
}
let entry = |pid: Uuid, group: &str, amount: i64| {
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
};
app.db
.collection::<Document>("entries")
.insert_many([
entry(closed[0], "needs", 100_000),
entry(closed[0], "wants", 41_000),
entry(closed[1], "needs", 200_000),
entry(closed[1], "needs", 50_000),
entry(closed[1], "culture", 10_000),
])
.await
.unwrap();
let pid = open_december(&app, &s, 0).await;
let (_, prev) = app
.get(
&format!("/periods/{pid}/budget/suggestion?method=previous"),
&s,
)
.await;
assert_eq!(prev["available"], true, "{prev}");
assert_eq!(
prev["amounts"],
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
);
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
let (_, avg) = app
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
.await;
assert_eq!(
avg["amounts"],
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
);
app.cleanup().await;
}
#[tokio::test]
async fn budget_closed_period_and_isolation() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let pid = open_december(&app, &a, 0).await;
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
// B erişemez
let checks = [
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
.await
.0,
app.put(
&format!("/periods/{pid}/budget/weeks"),
&b,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }),
)
.await
.0,
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
.await
.0,
];
assert!(
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
"{checks:?}"
);
// Kapalı dönem: okuma serbest, yazma 409
let id = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
assert_eq!(
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
StatusCode::OK
);
assert_eq!(
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
StatusCode::CONFLICT
);
app.cleanup().await;
}