7f3fde074c
- /api/periods/{id}/plan-items: listele, ekle; /api/plan-items/{id}: düzenle, sil
- Önceki dönemden kopyala: göreli gün korunur, dönem sonuna kırpılır, aynı kalem tekrar eklenmez
- /api/periods/{id}/notes (planning | review), /api/notes/{id}: Markdown notlar
- Kapalı dönemde değişiklik yok; silme geri alınabilir şekilde işaretlenir
- Entegrasyon testleri (5): kurallar, kopyalama, salt okunur dönem, hane izolasyonu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
12 KiB
Rust
376 lines
12 KiB
Rust
//! Plan kalemleri ve notlar.
|
||
|
||
mod common;
|
||
|
||
use axum::http::StatusCode;
|
||
use common::{Session, TestApp};
|
||
use mongodb::bson::{Document, 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()
|
||
}
|
||
|
||
async fn close_period(app: &TestApp, id: &str) {
|
||
let uuid = mongodb::bson::Uuid::parse_str(id).unwrap();
|
||
app.db
|
||
.collection::<Document>("periods")
|
||
.update_one(
|
||
doc! { "_id": uuid },
|
||
doc! { "$set": { "status": "closed" } },
|
||
)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
fn names(list: &Value) -> Vec<String> {
|
||
list.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|i| {
|
||
format!(
|
||
"{}:{}",
|
||
i["kind"].as_str().unwrap(),
|
||
i["name"].as_str().unwrap()
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn plan_items_crud_and_validation() {
|
||
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 items = format!("/periods/{pid}/plan-items");
|
||
|
||
// Gider önce eklense de liste gelirle başlar
|
||
let (st, rent) = app
|
||
.post(
|
||
&items,
|
||
&s,
|
||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||
)
|
||
.await;
|
||
assert_eq!(st, StatusCode::CREATED, "{rent}");
|
||
app.post(
|
||
&items,
|
||
&s,
|
||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||
)
|
||
.await;
|
||
app.post(
|
||
&items,
|
||
&s,
|
||
json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }),
|
||
)
|
||
.await;
|
||
let (_, list) = app.get(&items, &s).await;
|
||
assert_eq!(
|
||
names(&list),
|
||
vec!["income:Maaş", "expense:Kira", "expense:İnternet"]
|
||
);
|
||
assert_eq!(list[0]["date"], Value::Null);
|
||
|
||
// Doğrulamalar
|
||
for bad in [
|
||
json!({ "kind": "expense", "name": "X", "amount": 0 }),
|
||
json!({ "kind": "expense", "name": " ", "amount": 100 }),
|
||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç)
|
||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }),
|
||
json!({ "kind": "gift", "name": "X", "amount": 100 }),
|
||
] {
|
||
let (st, _) = app.post(&items, &s, bad.clone()).await;
|
||
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}");
|
||
}
|
||
|
||
// Düzenle: tutar + tarihi kaldır; eski sürüm → 409
|
||
let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap());
|
||
let (st, upd) = app
|
||
.patch(
|
||
&rent_path,
|
||
&s,
|
||
json!({ "amount": 1_600_000, "date": "", "version": 1 }),
|
||
)
|
||
.await;
|
||
assert_eq!(st, StatusCode::OK, "{upd}");
|
||
assert_eq!(
|
||
(upd["amount"].as_i64(), &upd["date"]),
|
||
(Some(1_600_000), &Value::Null)
|
||
);
|
||
let (st, _) = app
|
||
.patch(&rent_path, &s, json!({ "name": "Y", "version": 1 }))
|
||
.await;
|
||
assert_eq!(st, StatusCode::CONFLICT);
|
||
|
||
// Sil → listeden çıkar
|
||
assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT);
|
||
let (_, list) = app.get(&items, &s).await;
|
||
assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]);
|
||
|
||
app.cleanup().await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn copy_previous_shifts_dates_and_skips_duplicates() {
|
||
let app = TestApp::new().await;
|
||
let s = app.register("Test", None).await;
|
||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||
let dec_items = format!("/periods/{dec}/plan-items");
|
||
app.post(
|
||
&dec_items,
|
||
&s,
|
||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }),
|
||
)
|
||
.await;
|
||
app.post(
|
||
&dec_items,
|
||
&s,
|
||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||
)
|
||
.await;
|
||
app.post(
|
||
&dec_items,
|
||
&s,
|
||
json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }),
|
||
)
|
||
.await;
|
||
app.post(
|
||
&dec_items,
|
||
&s,
|
||
json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }),
|
||
)
|
||
.await;
|
||
close_period(&app, &dec).await;
|
||
|
||
let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await;
|
||
let jan_items = format!("/periods/{jan}/plan-items");
|
||
// Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı
|
||
app.post(
|
||
&jan_items,
|
||
&s,
|
||
json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }),
|
||
)
|
||
.await;
|
||
|
||
let (st, res) = app
|
||
.post(&format!("{jan_items}/copy-previous"), &s, json!({}))
|
||
.await;
|
||
assert_eq!(st, StatusCode::OK, "{res}");
|
||
assert_eq!(res["added"], 3);
|
||
|
||
let (_, list) = app.get(&jan_items, &s).await;
|
||
let by_name = |n: &str| {
|
||
list.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|i| i["name"] == n)
|
||
.unwrap()
|
||
.clone()
|
||
};
|
||
assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur");
|
||
assert_eq!(by_name("Aidat")["date"], Value::Null);
|
||
assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur");
|
||
|
||
assert_eq!(by_name("Fatura")["date"], "2027-01-31");
|
||
|
||
// Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir
|
||
close_period(&app, &jan).await;
|
||
let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await;
|
||
let feb_items = format!("/periods/{feb}/plan-items");
|
||
app.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||
.await;
|
||
let (_, feb_list) = app.get(&feb_items, &s).await;
|
||
let fatura = feb_list
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|i| i["name"] == "Fatura")
|
||
.unwrap();
|
||
assert_eq!(fatura["date"], "2027-02-28");
|
||
|
||
// İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz)
|
||
let (_, res) = app
|
||
.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||
.await;
|
||
assert_eq!(res["added"], 0);
|
||
|
||
// İlk dönemde önceki yok → 400
|
||
let (st, _) = app
|
||
.post(&format!("{dec_items}/copy-previous"), &s, json!({}))
|
||
.await;
|
||
assert!(
|
||
st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT,
|
||
"{st}"
|
||
);
|
||
|
||
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 (_, item) = app
|
||
.post(
|
||
&format!("/periods/{pid}/plan-items"),
|
||
&s,
|
||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||
)
|
||
.await;
|
||
let (_, note) = app
|
||
.post(
|
||
&format!("/periods/{pid}/notes"),
|
||
&s,
|
||
json!({ "context": "planning", "title": "Hedef" }),
|
||
)
|
||
.await;
|
||
close_period(&app, &pid).await;
|
||
|
||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||
let checks = [
|
||
app.post(
|
||
&format!("/periods/{pid}/plan-items"),
|
||
&s,
|
||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||
)
|
||
.await
|
||
.0,
|
||
app.patch(&item_path, &s, json!({ "amount": 5 })).await.0,
|
||
app.delete(&item_path, &s).await.0,
|
||
app.post(
|
||
&format!("/periods/{pid}/notes"),
|
||
&s,
|
||
json!({ "context": "review", "title": "X" }),
|
||
)
|
||
.await
|
||
.0,
|
||
app.patch(¬e_path, &s, json!({ "title": "Y" })).await.0,
|
||
app.delete(¬e_path, &s).await.0,
|
||
];
|
||
assert!(
|
||
checks.iter().all(|st| *st == StatusCode::CONFLICT),
|
||
"{checks:?}"
|
||
);
|
||
|
||
// Okuma serbest
|
||
assert_eq!(
|
||
app.get(&format!("/periods/{pid}/plan-items"), &s).await.0,
|
||
StatusCode::OK
|
||
);
|
||
assert_eq!(
|
||
app.get(&format!("/periods/{pid}/notes"), &s).await.0,
|
||
StatusCode::OK
|
||
);
|
||
|
||
app.cleanup().await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn notes_crud_and_context_filter() {
|
||
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 notes = format!("/periods/{pid}/notes");
|
||
|
||
let (st, n) = app
|
||
.post(¬es, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" }))
|
||
.await;
|
||
assert_eq!(st, StatusCode::CREATED, "{n}");
|
||
assert_eq!(n["title"], "Tatil birikimi");
|
||
app.post(¬es, &s, json!({ "context": "review", "title": "Ders" }))
|
||
.await;
|
||
|
||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||
assert_eq!(planning.as_array().unwrap().len(), 1);
|
||
let (_, all) = app.get(¬es, &s).await;
|
||
assert_eq!(all.as_array().unwrap().len(), 2);
|
||
|
||
let (st, _) = app
|
||
.post(¬es, &s, json!({ "context": "planning", "title": "" }))
|
||
.await;
|
||
assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık");
|
||
|
||
let path = format!("/notes/{}", n["id"].as_str().unwrap());
|
||
let (st, upd) = app
|
||
.patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 }))
|
||
.await;
|
||
assert_eq!(
|
||
(st, upd["body_md"].as_str()),
|
||
(StatusCode::OK, Some("Güncel"))
|
||
);
|
||
assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT);
|
||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||
assert_eq!(planning, json!([]));
|
||
|
||
app.cleanup().await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn plan_items_and_notes_are_isolated() {
|
||
let app = TestApp::new().await;
|
||
let a = app.register("A", None).await;
|
||
let b = app.register("B", None).await;
|
||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||
let (_, item) = app
|
||
.post(
|
||
&format!("/periods/{pid}/plan-items"),
|
||
&a,
|
||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||
)
|
||
.await;
|
||
let (_, note) = app
|
||
.post(
|
||
&format!("/periods/{pid}/notes"),
|
||
&a,
|
||
json!({ "context": "planning", "title": "Gizli" }),
|
||
)
|
||
.await;
|
||
|
||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||
let checks = [
|
||
app.get(&format!("/periods/{pid}/plan-items"), &b).await.0,
|
||
app.post(
|
||
&format!("/periods/{pid}/plan-items"),
|
||
&b,
|
||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||
)
|
||
.await
|
||
.0,
|
||
app.post(
|
||
&format!("/periods/{pid}/plan-items/copy-previous"),
|
||
&b,
|
||
json!({}),
|
||
)
|
||
.await
|
||
.0,
|
||
app.patch(&item_path, &b, json!({ "amount": 1 })).await.0,
|
||
app.delete(&item_path, &b).await.0,
|
||
app.get(&format!("/periods/{pid}/notes"), &b).await.0,
|
||
app.patch(¬e_path, &b, json!({ "title": "X" })).await.0,
|
||
app.delete(¬e_path, &b).await.0,
|
||
];
|
||
assert!(
|
||
checks.iter().all(|st| *st == StatusCode::NOT_FOUND),
|
||
"{checks:?}"
|
||
);
|
||
|
||
// A'nın verisi değişmedi
|
||
let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await;
|
||
assert_eq!(list[0]["amount"], 100);
|
||
assert_eq!(list[0]["version"], 1);
|
||
|
||
app.cleanup().await;
|
||
}
|