2003b8fad7
- /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>
454 lines
14 KiB
Rust
454 lines
14 KiB
Rust
//! 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;
|
||
}
|