feat(defter): harcama kayıtları — ekle, düzenle, sil, geri al (backend)
- /periods/{id}/entries (from/to filtresi), /entries/{id}, /entries/{id}/restore
- Grup kategoriden türetilir; kategori/ödeme türü hane ve aktiflik kontrolü
- Yalnızca açık dönem; soft delete + geri al; sürüm çakışmasında 409
- 6 entegrasyon testi (izolasyon, kapalı dönem, doğrulama, önceki dönem önerisi)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
//! 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;
|
||||
}
|
||||
Reference in New Issue
Block a user