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:
@@ -300,3 +300,16 @@ DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID
|
||||
|
||||
### Silmeyi geri al ("↩️ Geri al")
|
||||
POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore
|
||||
|
||||
# ─────────────────────────── Ay sonu değerlendirmesi ───────────────────────────
|
||||
|
||||
### Değerlendirme: 4 soru (available, savings_goal, spent_total, realized_savings) + grup/kategori/hafta
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/review
|
||||
|
||||
### "Nasıl daha iyi yapabilirim?" (Markdown; boş metin → kaldır; yalnızca açık dönem)
|
||||
PATCH {{baseUrl}}/periods/BURAYA-DONEM-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,8 @@ pub struct Period {
|
||||
pub status: PeriodStatus,
|
||||
/// Kuruş
|
||||
pub savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||
#[serde(default)]
|
||||
pub reflection_md: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod review;
|
||||
mod spa;
|
||||
|
||||
use axum::{
|
||||
@@ -78,6 +79,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
|
||||
.route("/periods/{id}/notes", get(note::list).post(note::create))
|
||||
.route("/periods/{id}/summary", get(budget::summary))
|
||||
.route("/periods/{id}/review", get(review::get))
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct UpdateRequest {
|
||||
end_date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
savings_goal: Option<i64>,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
|
||||
reflection_md: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -73,6 +75,8 @@ pub struct PeriodDto {
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||
reflection_md: Option<String>,
|
||||
version: i32,
|
||||
weeks: Vec<WeekDto>,
|
||||
}
|
||||
@@ -102,6 +106,7 @@ impl From<Period> for PeriodDto {
|
||||
end_date: p.end_date,
|
||||
status: p.status,
|
||||
savings_goal: p.savings_goal,
|
||||
reflection_md: p.reflection_md,
|
||||
version: p.version,
|
||||
weeks: weeks(start, end)
|
||||
.into_iter()
|
||||
@@ -178,6 +183,7 @@ pub async fn update(
|
||||
start_date: req.start_date,
|
||||
end_date: req.end_date,
|
||||
savings_goal: req.savings_goal,
|
||||
reflection_md: req.reflection_md,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Ay sonu değerlendirmesi:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/review` | 4 soru, grup/kategori/hafta bazında planlanan–gerçekleşen |
|
||||
//!
|
||||
//! "Nasıl daha iyi yapabilirim?" metni: `PATCH /api/periods/{id}` → `reflection_md`.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
models::group::Group,
|
||||
services::review,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodReview")]
|
||||
pub struct ReviewDto {
|
||||
#[ts(type = "number")]
|
||||
income_total: i64,
|
||||
#[ts(type = "number")]
|
||||
fixed_expense_total: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_debt: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
/// 1. Ne kadar param vardı? (gelir − sabit gider − borç + artı)
|
||||
#[ts(type = "number")]
|
||||
available: i64,
|
||||
/// 2. Ne kadar biriktirmek istedim?
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// 3. Ne kadar harcadım?
|
||||
#[ts(type = "number")]
|
||||
spent_total: i64,
|
||||
/// 4. Ne kadar biriktirdim? (negatifse açık var)
|
||||
#[ts(type = "number")]
|
||||
realized_savings: i64,
|
||||
/// Gerçekleşen − hedef birikim (negatifse hedefin altında)
|
||||
#[ts(type = "number")]
|
||||
savings_gap: i64,
|
||||
/// 4 grup, sabit sırada
|
||||
groups: Vec<ReviewGroupDto>,
|
||||
/// Harcama olan kategoriler, çoktan aza
|
||||
categories: Vec<ReviewCategoryDto>,
|
||||
/// Haftalar: 4 grubun toplamı
|
||||
weeks: Vec<ReviewWeekDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReviewGroup")]
|
||||
struct ReviewGroupDto {
|
||||
group: Group,
|
||||
/// Aylık bütçe
|
||||
#[ts(type = "number")]
|
||||
planned: i64,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReviewCategory")]
|
||||
struct ReviewCategoryDto {
|
||||
category_id: String,
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
/// Kayıt sayısı
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReviewWeek")]
|
||||
struct ReviewWeekDto {
|
||||
index: u32,
|
||||
start_date: String,
|
||||
last_date: String,
|
||||
#[ts(type = "number")]
|
||||
planned: i64,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<ReviewDto>> {
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||
let r = review::review(&s.db, auth.household_id, id).await?;
|
||||
|
||||
// Haftalar: her grubun aynı sıradaki haftası toplanır (hepsi aynı takvimden gelir)
|
||||
let weeks = r
|
||||
.groups
|
||||
.first()
|
||||
.map(|g| {
|
||||
g.weeks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| ReviewWeekDto {
|
||||
index: w.index,
|
||||
start_date: w.start_date.clone(),
|
||||
last_date: w.last_date.clone(),
|
||||
planned: r.groups.iter().map(|g| g.weeks[i].amount).sum(),
|
||||
spent: r.groups.iter().map(|g| g.weeks[i].spent).sum(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(ReviewDto {
|
||||
income_total: r.summary.income_total,
|
||||
fixed_expense_total: r.summary.fixed_expense_total,
|
||||
carryover_debt: r.summary.carryover_debt,
|
||||
carryover_surplus: r.summary.carryover_surplus,
|
||||
available: r.available,
|
||||
savings_goal: r.summary.savings_goal,
|
||||
spent_total: r.summary.spent_total,
|
||||
realized_savings: r.realized_savings,
|
||||
savings_gap: r.realized_savings - r.summary.savings_goal,
|
||||
groups: r
|
||||
.groups
|
||||
.iter()
|
||||
.map(|g| ReviewGroupDto {
|
||||
group: g.group,
|
||||
planned: g.monthly,
|
||||
spent: g.spent,
|
||||
})
|
||||
.collect(),
|
||||
categories: r
|
||||
.categories
|
||||
.into_iter()
|
||||
.map(|c| ReviewCategoryDto {
|
||||
category_id: c.category_id.to_string(),
|
||||
group: c.group,
|
||||
spent: c.spent,
|
||||
count: c.count,
|
||||
})
|
||||
.collect(),
|
||||
weeks,
|
||||
}))
|
||||
}
|
||||
@@ -10,4 +10,5 @@ pub mod money;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
|
||||
@@ -41,9 +41,14 @@ pub struct PeriodChanges {
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
/// `Some("")` → metni kaldır
|
||||
pub reflection_md: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
|
||||
pub const MAX_REFLECTION: usize = 20_000;
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Period> {
|
||||
db.collection(col::PERIODS)
|
||||
}
|
||||
@@ -177,6 +182,9 @@ pub async fn update(
|
||||
if let Some(goal) = ch.savings_goal {
|
||||
set.insert("savings_goal", validate_amount(goal)?);
|
||||
}
|
||||
if let Some(text) = ch.reflection_md {
|
||||
set.insert("reflection_md", clean_reflection(&text)?);
|
||||
}
|
||||
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
@@ -190,6 +198,19 @@ pub async fn update(
|
||||
|
||||
// ---------- doğrulamalar ----------
|
||||
|
||||
fn clean_reflection(text: &str) -> AppResult<Bson> {
|
||||
let t = text.trim();
|
||||
if t.is_empty() {
|
||||
return Ok(Bson::Null);
|
||||
}
|
||||
if t.chars().count() > MAX_REFLECTION {
|
||||
return Err(AppError::BadRequest(
|
||||
"Metin en fazla 20.000 karakter olabilir".into(),
|
||||
));
|
||||
}
|
||||
Ok(Bson::String(t.to_string()))
|
||||
}
|
||||
|
||||
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
|
||||
let bad = |m: &str| AppError::BadRequest(m.into());
|
||||
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Ay sonu değerlendirmesi (KAKEIBO_PROMPT §8.5, DATA_MODEL.md §5).
|
||||
//!
|
||||
//! Kakeibo'nun dört sorusu — hepsi sorgu anında hesaplanır, saklanmaz:
|
||||
//! 1. Ne kadar param vardı? `available` = gelir − sabit gider − devreden borç + devreden artı
|
||||
//! 2. Ne kadar biriktirmek istedim? `savings_goal`
|
||||
//! 3. Ne kadar harcadım? `spent_total`
|
||||
//! 4. Ne kadar biriktirdim? `realized_savings` = available − spent_total
|
||||
//!
|
||||
//! Ek olarak: grup bazlı planlanan/gerçekleşen, kategori bazlı harcama, haftalık dağılım.
|
||||
//! Kapalı dönemde de okunabilir.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::AppResult,
|
||||
models::{common::scope, group::Group},
|
||||
services::budget::{self, GroupBudget, Summary},
|
||||
};
|
||||
|
||||
pub struct Review {
|
||||
pub summary: Summary,
|
||||
/// 1. soru
|
||||
pub available: i64,
|
||||
/// 4. soru (negatifse açık var → kapanışta borç olarak devreder)
|
||||
pub realized_savings: i64,
|
||||
/// Grup: aylık bütçe ve gerçekleşen (`Group::ALL` sırasıyla)
|
||||
pub groups: Vec<GroupBudget>,
|
||||
/// Harcama olan kategoriler, çoktan aza
|
||||
pub categories: Vec<CategorySpent>,
|
||||
}
|
||||
|
||||
pub struct CategorySpent {
|
||||
pub category_id: Uuid,
|
||||
pub group: Group,
|
||||
pub spent: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub async fn review(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Review> {
|
||||
// `summary` dönemin bu haneye ait olduğunu da denetler (yoksa 404)
|
||||
let summary = budget::summary(db, household_id, period_id).await?;
|
||||
let groups = budget::view(db, household_id, period_id).await?;
|
||||
let categories = spent_by_category(db, household_id, period_id).await?;
|
||||
let available = summary.income_total - summary.fixed_expense_total - summary.carryover_debt
|
||||
+ summary.carryover_surplus;
|
||||
Ok(Review {
|
||||
realized_savings: available - summary.spent_total,
|
||||
available,
|
||||
summary,
|
||||
groups,
|
||||
categories,
|
||||
})
|
||||
}
|
||||
|
||||
async fn spent_by_category(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Vec<CategorySpent>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Key {
|
||||
category_id: Uuid,
|
||||
group: Group,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Row {
|
||||
#[serde(rename = "_id")]
|
||||
key: Key,
|
||||
total: i64,
|
||||
count: i64,
|
||||
}
|
||||
let mut m = scope(household_id);
|
||||
m.insert("period_id", period_id);
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$group": {
|
||||
"_id": { "category_id": "$category_id", "group": "$group" },
|
||||
"total": { "$sum": "$amount" },
|
||||
"count": { "$sum": 1_i64 },
|
||||
} },
|
||||
// Eşit tutarda sıra her seferinde aynı olsun
|
||||
doc! { "$sort": { "total": -1, "_id.category_id": 1 } },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Row>()
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
let r = cursor.deserialize_current()?;
|
||||
out.push(CategorySpent {
|
||||
category_id: r.key.category_id,
|
||||
group: r.key.group,
|
||||
spent: r.total,
|
||||
count: r.count,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user