feat(değerlendirme): ay sonu değerlendirmesi — 4 soru ve döküm (backend)

- GET /periods/{id}/review: param, hedef, harcanan, biriken; grup, kategori ve hafta bazında planlanan/gerçekleşen
- Dönemde "Nasıl daha iyi yapabilirim?" metni (reflection_md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 11:54:57 +03:00
parent e4c8d971fc
commit 8ec1fd6f25
18 changed files with 625 additions and 4 deletions
+241
View File
@@ -0,0 +1,241 @@
//! Ay sonu değerlendirmesi: dört soru, grup/kategori/hafta dökümü, "Nasıl daha iyi yapabilirim?".
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()
}
/// Gruptaki n'inci (0'dan) örnek kategori
async fn category(app: &TestApp, s: &Session, group: &str, n: usize) -> String {
let (_, cats) = app.get("/categories", s).await;
cats.as_array()
.unwrap()
.iter()
.filter(|c| c["group"] == group)
.nth(n)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
let (st, e) = app
.post(
&format!("/periods/{pid}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
e
}
async fn plan(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 column(list: &Value, key: &str) -> Vec<i64> {
list.as_array()
.unwrap()
.iter()
.map(|x| x[key].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn four_questions_and_breakdowns() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
plan(&app, &s, &pid, "income", 4_800_000).await;
plan(&app, &s, &pid, "expense", 1_545_000).await;
app.db
.collection::<Document>("carryovers")
.insert_one(doc! {
"household_id": Uuid::parse_str(&s.household_id).unwrap(),
"to_period_id": Uuid::parse_str(&pid).unwrap(),
"kind": "debt", "amount": 100_000_i64, "deleted_at": null,
})
.await
.unwrap();
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 1_000_000, "wants": 500_000, "culture": 200_000, "unexpected": 100_000 }),
)
.await;
let (market, rent) = (
category(&app, &s, "needs", 0).await,
category(&app, &s, "needs", 1).await,
);
let wants = category(&app, &s, "wants", 0).await;
let culture = category(&app, &s, "culture", 0).await;
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
spend(&app, &s, &pid, "2026-12-01", 100_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await;
spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await;
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await;
let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await;
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await;
let (st, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
assert_eq!(st, StatusCode::OK, "{r}");
// 1: 4.800.000 − 1.545.000 − 100.000 = 3.155.000 · 3: 500.000 · 4: 3.155.000 − 500.000
for (k, v) in [
("available", 3_155_000),
("savings_goal", 500_000),
("spent_total", 500_000),
("realized_savings", 2_655_000),
("savings_gap", 2_155_000),
("carryover_debt", 100_000),
] {
assert_eq!(r[k], v, "{k}: {r}");
}
assert_eq!(
r["groups"],
json!([
{ "group": "needs", "planned": 1_000_000, "spent": 450_000 },
{ "group": "wants", "planned": 500_000, "spent": 40_000 },
{ "group": "culture", "planned": 200_000, "spent": 10_000 },
{ "group": "unexpected", "planned": 100_000, "spent": 0 },
])
);
// Kategoriler çoktan aza; silinen kayıt sayılmaz
let cats = r["categories"].as_array().unwrap();
assert_eq!(
column(&r["categories"], "spent"),
vec![300_000, 150_000, 40_000, 10_000]
);
assert_eq!(
(
cats[0]["category_id"].as_str(),
cats[1]["category_id"].as_str()
),
(Some(rent.as_str()), Some(market.as_str()))
);
assert_eq!(
(cats[1]["count"].as_i64(), cats[1]["group"].as_str()),
(Some(2), Some("needs"))
);
// Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı
assert_eq!(
column(&r["weeks"], "spent"),
vec![100_000, 350_000, 40_000, 0, 10_000]
);
assert_eq!(
column(&r["weeks"], "planned").iter().sum::<i64>(),
1_800_000
);
assert_eq!(r["weeks"][4]["last_date"], "2026-12-31");
app.cleanup().await;
}
#[tokio::test]
async fn overspending_gives_negative_savings() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 100_000).await;
plan(&app, &s, &pid, "income", 200_000).await;
let needs = category(&app, &s, "needs", 0).await;
spend(&app, &s, &pid, "2026-12-10", 250_000, &needs).await;
let (_, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
assert_eq!(
(
r["available"].as_i64(),
r["realized_savings"].as_i64(),
r["savings_gap"].as_i64()
),
(Some(200_000), Some(-50_000), Some(-150_000))
);
// Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden
assert_eq!(column(&r["groups"], "planned"), vec![0; 4]);
assert_eq!(r["weeks"].as_array().unwrap().len(), 5);
app.cleanup().await;
}
#[tokio::test]
async fn reflection_text_and_access_rules() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let other = app.register("Diger", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}");
let (st, p) = app
.patch(
&url,
&s,
json!({ "reflection_md": " **Dışarıda yemeği** azalt. " }),
)
.await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
let (_, p) = app.get(&url, &s).await;
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
// Türkçe karakterler tek sayılır; sınırın üstü 400
let (st, _) = app
.patch(&url, &s, json!({ "reflection_md": "ş".repeat(20_000) }))
.await;
assert_eq!(st, StatusCode::OK);
let (st, b) = app
.patch(&url, &s, json!({ "reflection_md": "a".repeat(20_001) }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(
b["error"]["message"],
"Metin en fazla 20.000 karakter olabilir"
);
// Boş metin → kaldırılır
let (_, p) = app.patch(&url, &s, json!({ "reflection_md": "" })).await;
assert_eq!(p["reflection_md"], Value::Null);
// Başka hane göremez
let (st, _) = app.get(&format!("{url}/review"), &other).await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Kapalı dönem: değerlendirme okunur, metin değiştirilemez
app.db
.collection::<Document>("periods")
.update_one(
doc! { "_id": Uuid::parse_str(&pid).unwrap() },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
let (st, _) = app.get(&format!("{url}/review"), &s).await;
assert_eq!(st, StatusCode::OK);
let (st, _) = app.patch(&url, &s, json!({ "reflection_md": "geç" })).await;
assert_eq!(st, StatusCode::CONFLICT);
app.cleanup().await;
}