feat(dönem): dönem açma, açık dönem, düzenleme ve haftalar (backend)

- /api/periods: liste, açık dönem, aç, detay, düzenle
- Kurallar: tek açık dönem, çakışma yok, bitiş hariç, en fazla 62 gün, kapalı dönem salt okunur
- Tarih daraltılırken dışarıda harcama kalırsa engellenir
- Haftalar Pazartesi başlangıçlı, kısmi haftalar ayrı (takvim yardımcıları)
- Otomatik dönem adı: "Aralık 2026" / "15 Ara 2026 – 14 Oca 2027"
- Testler: takvim (4), dönem kuralları (2), entegrasyon (5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 02:56:13 +03:00
parent a2b4119f3e
commit b883530f76
16 changed files with 1048 additions and 1 deletions
+244
View File
@@ -0,0 +1,244 @@
//! Dönem kuralları (KAKEIBO_PROMPT §6).
mod common;
use axum::http::StatusCode;
use common::TestApp;
use mongodb::bson::{Document, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &common::Session) -> Value {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500000 }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p
}
#[tokio::test]
async fn open_period_returns_weeks_and_default_name() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
// Başta açık dönem yok → null
let (st, cur) = app.get("/periods/current", &s).await;
assert_eq!((st, cur), (StatusCode::OK, Value::Null));
let p = open_december(&app, &s).await;
assert_eq!(p["name"], "Aralık 2026");
assert_eq!(p["status"], "open");
assert_eq!(p["days"], 31);
assert_eq!(p["last_date"], "2026-12-31");
assert_eq!(p["savings_goal"], 500000);
let days: Vec<i64> = p["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["days"].as_i64().unwrap())
.collect();
assert_eq!(
days,
vec![6, 7, 7, 7, 4],
"Pazartesi başlangıçlı, kısmi haftalar ayrı"
);
let (_, cur) = app.get("/periods/current", &s).await;
assert_eq!(cur["id"], p["id"]);
app.cleanup().await;
}
#[tokio::test]
async fn only_one_open_period_and_no_overlap() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let dec = open_december(&app, &s).await;
// İkinci açık dönem → 409
let (st, body) = app
.post(
"/periods",
&s,
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("Aralık 2026")
);
// Aralık'ı kapat (Faz 6'da sihirbaz yapacak) → çakışan yeni dönem yine 409
app.db
.collection::<Document>("periods")
.update_one(
doc! { "name": "Aralık 2026" },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
let (st, body) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-12-20", "end_date": "2027-01-20" }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "{body}");
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("çakışıyor")
);
// Bitişik dönem (bitiş hariç olduğu için 1 Ocak serbest) → 201
let (st, jan) = app
.post(
"/periods",
&s,
json!({ "start_date": "2027-01-01", "end_date": "2027-02-01" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{jan}");
assert_eq!(jan["name"], "Ocak 2027");
// Liste: en yeni başta
let (_, list) = app.get("/periods", &s).await;
let names: Vec<&str> = list
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["Ocak 2027", "Aralık 2026"]);
// Kapalı dönem salt okunur
let (st, _) = app
.patch(
&format!("/periods/{}", dec["id"].as_str().unwrap()),
&s,
json!({ "savings_goal": 1 }),
)
.await;
assert_eq!(st, StatusCode::CONFLICT);
app.cleanup().await;
}
#[tokio::test]
async fn validation_errors() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
for (start, end) in [
("2026-12-10", "2026-12-01"), // ters
("2026-12-01", "2026-12-01"), // boş
("2026-02-30", "2026-03-30"), // geçersiz gün
("01.12.2026", "2027-01-01"), // biçim
("2026-12-01", "2027-03-01"), // 90 gün
] {
let (st, _) = app
.post(
"/periods",
&s,
json!({ "start_date": start, "end_date": end }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{start} → {end}");
}
let (st, _) = app
.post(
"/periods",
&s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": -5 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "negatif birikim");
app.cleanup().await;
}
#[tokio::test]
async fn update_dates_name_goal_and_version() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let p = open_december(&app, &s).await;
let path = format!("/periods/{}", p["id"].as_str().unwrap());
// Maaş 15'inde → dönemi kaydır; ad otomatik güncellenmez, istenirse verilir
let (st, upd) = app
.patch(
&path,
&s,
json!({ "start_date": "2026-12-15", "end_date": "2027-01-15", "name": "", "savings_goal": 750000, "version": 1 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{upd}");
assert_eq!(upd["name"], "15 Ara 2026 – 14 Oca 2027");
assert_eq!(upd["savings_goal"], 750000);
assert_eq!(upd["version"], 2);
// Eski sürümle → 409
let (st, _) = app
.patch(&path, &s, json!({ "name": "X", "version": 1 }))
.await;
assert_eq!(st, StatusCode::CONFLICT);
// Aralığın dışında kalacak bir harcama varsa daraltma engellenir
let hid = mongodb::bson::Uuid::parse_str(&s.household_id).unwrap();
let pid = mongodb::bson::Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
app.db
.collection::<Document>("entries")
.insert_one(doc! { "household_id": hid, "period_id": pid, "date": "2027-01-10", "deleted_at": null })
.await
.unwrap();
let (st, body) = app
.patch(&path, &s, json!({ "end_date": "2027-01-05" }))
.await;
assert_eq!(st, StatusCode::CONFLICT, "{body}");
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("1 harcama")
);
app.cleanup().await;
}
#[tokio::test]
async fn periods_are_isolated_between_households() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let p = open_december(&app, &a).await;
let path = format!("/periods/{}", p["id"].as_str().unwrap());
// B, A'nın dönemini göremez/değiştiremez; kendi açık dönemi yok
assert_eq!(app.get(&path, &b).await.0, StatusCode::NOT_FOUND);
assert_eq!(
app.patch(&path, &b, json!({ "name": "ele geçirildi" }))
.await
.0,
StatusCode::NOT_FOUND
);
assert_eq!(app.get("/periods/current", &b).await.1, Value::Null);
assert_eq!(app.get("/periods", &b).await.1, json!([]));
// A'nın açık dönemi B'nin dönem açmasını engellemez (açık dönem kuralı hane bazlı)
let (st, _) = app
.post(
"/periods",
&b,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
)
.await;
assert_eq!(st, StatusCode::CREATED);
app.cleanup().await;
}