feat(katalog): kategori ve ödeme türü uç noktaları, hane izolasyonu entegrasyon testleri

- /api/categories ve /api/payment-methods: listele, ekle, düzenle, sırala, pasife al
- Sürüm kontrolü ile eşzamanlı düzenleme çakışması (409)
- Backend lib + bin yapısına ayrıldı (entegrasyon testleri için)
- tests/: hane izolasyonu (3) ve katalog (4) entegrasyon testleri
- db:reset artık artık test veritabanlarını da siler
- api.http tamamlandı; Faz 1 tamamlandı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 23:54:16 +03:00
parent 4cc4fa5882
commit 6eb5440d4d
21 changed files with 1257 additions and 44 deletions
+318
View File
@@ -0,0 +1,318 @@
//! Kategoriler ve ödeme türleri için ortak iş mantığı (DATA_MODEL.md §3.5, §3.6).
//!
//! İkisi de aynı şekilde çalışır: listele, ekle, düzenle, sırala, pasife al.
//! Tek fark: kategoriler bir ana gruba (`group`) bağlıdır, ödeme türleri değil.
//! **Silme yoktur** — geçmiş harcama kayıtları bozulmasın diye yalnızca pasife alınır.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use serde::Deserialize;
use crate::{
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
group::Group,
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Catalog {
Categories,
PaymentMethods,
}
impl Catalog {
fn collection(self) -> &'static str {
match self {
Catalog::Categories => crate::db::col::CATEGORIES,
Catalog::PaymentMethods => crate::db::col::PAYMENT_METHODS,
}
}
fn has_group(self) -> bool {
self == Catalog::Categories
}
fn noun(self) -> &'static str {
match self {
Catalog::Categories => "kategori",
Catalog::PaymentMethods => "ödeme türü",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Item {
#[serde(rename = "_id")]
pub id: Uuid,
#[serde(default)]
pub group: Option<Group>,
pub name: String,
#[serde(default)]
pub emoji: Option<String>,
pub sort_order: i32,
pub is_active: bool,
pub version: i32,
}
#[derive(Debug, Default)]
pub struct Changes {
pub name: Option<String>,
/// `Some("")` → emoji kaldırılır
pub emoji: Option<String>,
pub is_active: Option<bool>,
/// İstemcinin gördüğü sürüm; farklıysa başkası değiştirmiş demektir → 409
pub version: Option<i32>,
}
fn coll(db: &Database, c: Catalog) -> mongodb::Collection<Item> {
db.collection(c.collection())
}
/// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır.
pub async fn list(
db: &Database,
c: Catalog,
household_id: Uuid,
include_inactive: bool,
) -> AppResult<Vec<Item>> {
let mut filter = scope(household_id);
if !include_inactive {
filter.insert("is_active", true);
}
let mut items = collect(coll(db, c).find(filter).await?).await?;
items.sort_by_key(|i| (i.group.map(Group::order), i.sort_order));
Ok(items)
}
pub async fn create(
db: &Database,
c: Catalog,
household_id: Uuid,
group: Option<Group>,
name: String,
emoji: Option<String>,
) -> AppResult<Item> {
let group = check_group(c, group)?;
let name = clean_name(&name)?;
ensure_unique_name(db, c, household_id, group, &name, None).await?;
// Grubun (veya listenin) en sonuna ekle
let mut last_filter = scope(household_id);
if let Some(g) = group {
last_filter.insert("group", g.as_str());
}
let last = coll(db, c)
.find_one(last_filter)
.sort(doc! { "sort_order": -1 })
.await?;
let sort_order = last.map_or(10, |i| i.sort_order + 10);
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
if let Some(g) = group {
d.insert("group", g.as_str());
}
d.insert("name", name);
d.insert("emoji", clean_emoji(emoji));
d.insert("sort_order", sort_order);
d.insert("is_active", true);
db.collection::<Document>(c.collection())
.insert_one(d)
.await?;
coll(db, c)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into()))
}
pub async fn update(
db: &Database,
c: Catalog,
household_id: Uuid,
id: Uuid,
changes: Changes,
) -> AppResult<Item> {
let mut filter = scope(household_id);
filter.insert("_id", id);
let current = coll(db, c)
.find_one(filter.clone())
.await?
.ok_or(AppError::NotFound)?;
if changes.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(name) = changes.name {
let name = clean_name(&name)?;
if name != current.name {
ensure_unique_name(db, c, household_id, current.group, &name, Some(id)).await?;
}
set.insert("name", name);
}
if let Some(emoji) = changes.emoji {
set.insert("emoji", clean_emoji(Some(emoji)));
}
if let Some(active) = changes.is_active {
set.insert("is_active", active);
}
// Sürüm filtresi: okuma ile yazma arasında başkası değiştirdiyse güncelleme olmaz
filter.insert("version", current.version);
coll(db, c)
.find_one_and_update(filter, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
/// Sıralamayı verilen kimlik listesine göre yeniden yazar.
/// Liste, (kategorilerde ilgili gruptaki) **tüm** kayıtları içermelidir — pasifler dahil.
pub async fn reorder(
db: &Database,
c: Catalog,
household_id: Uuid,
group: Option<Group>,
ids: Vec<Uuid>,
) -> AppResult<Vec<Item>> {
let group = check_group(c, group)?;
let mut filter = scope(household_id);
if let Some(g) = group {
filter.insert("group", g.as_str());
}
let existing = collect(coll(db, c).find(filter).await?).await?;
let mut want: Vec<Uuid> = ids.clone();
let mut have: Vec<Uuid> = existing.iter().map(|i| i.id).collect();
want.sort_by_key(|u| u.bytes());
have.sort_by_key(|u| u.bytes());
if want != have {
return Err(AppError::BadRequest(
"Sıralama listesi eksik veya hatalı, sayfayı yenileyip tekrar deneyin".into(),
));
}
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let ts = now();
for (i, id) in ids.iter().enumerate() {
let r = db
.collection::<Document>(c.collection())
.update_one(
doc! { "_id": id, "household_id": household_id },
doc! {
"$set": { "sort_order": (i as i32 + 1) * 10, "updated_at": ts },
"$inc": { "version": 1 },
},
)
.session(&mut session)
.await;
if let Err(e) = r {
let _ = session.abort_transaction().await;
return Err(e.into());
}
}
session.commit_transaction().await?;
let all = list(db, c, household_id, true).await?;
Ok(all.into_iter().filter(|i| i.group == group).collect())
}
// ---------- yardımcılar ----------
fn check_group(c: Catalog, group: Option<Group>) -> AppResult<Option<Group>> {
match (c.has_group(), group) {
(true, None) => Err(AppError::BadRequest("Ana grup seçilmeli".into())),
(true, g) => Ok(g),
(false, _) => Ok(None), // ödeme türlerinde grup yok sayılır
}
}
fn clean_name(name: &str) -> AppResult<String> {
let name = name.trim();
match name.chars().count() {
0 => Err(AppError::BadRequest("Ad boş olamaz".into())),
1..=40 => Ok(name.to_string()),
_ => Err(AppError::BadRequest(
"Ad en fazla 40 karakter olabilir".into(),
)),
}
}
fn clean_emoji(emoji: Option<String>) -> Bson {
match emoji.map(|e| e.trim().to_string()) {
Some(e) if !e.is_empty() => Bson::String(e),
_ => Bson::Null,
}
}
/// Aynı grupta (pasifler dahil) aynı adda kayıt olmasın. Büyük/küçük harf duyarsız.
async fn ensure_unique_name(
db: &Database,
c: Catalog,
household_id: Uuid,
group: Option<Group>,
name: &str,
exclude: Option<Uuid>,
) -> AppResult<()> {
let mut filter = scope(household_id);
if let Some(g) = group {
filter.insert("group", g.as_str());
}
let wanted = name.to_lowercase();
let items = collect(coll(db, c).find(filter).await?).await?;
let clash = items
.iter()
.any(|i| Some(i.id) != exclude && i.name.to_lowercase() == wanted);
if clash {
return Err(AppError::Conflict(format!(
"Bu adla bir {} zaten var (pasif olabilir)",
c.noun()
)));
}
Ok(())
}
fn stale() -> AppError {
AppError::Conflict("Bu kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
async fn collect(mut cursor: mongodb::Cursor<Item>) -> AppResult<Vec<Item>> {
let mut v = Vec::new();
while cursor.advance().await? {
v.push(cursor.deserialize_current()?);
}
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_rules() {
assert_eq!(clean_name(" Market ").unwrap(), "Market");
assert!(clean_name(" ").is_err());
assert!(
clean_name(&"ş".repeat(40)).is_ok(),
"Türkçe karakterler tek sayılmalı"
);
assert!(clean_name(&"a".repeat(41)).is_err());
}
#[test]
fn group_required_only_for_categories() {
assert!(check_group(Catalog::Categories, None).is_err());
assert_eq!(
check_group(Catalog::PaymentMethods, Some(Group::Needs)).unwrap(),
None
);
}
}
+1
View File
@@ -1,5 +1,6 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod catalog;
pub mod invite;
pub mod seed;