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
-1
View File
@@ -55,7 +55,6 @@ pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
}
/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit.
#[allow(dead_code)] // Bazıları sonraki fazlarda kullanılacak
pub mod col {
pub const USERS: &str = "users";
pub const HOUSEHOLDS: &str = "households";
+13
View File
@@ -0,0 +1,13 @@
//! Kakeibo backend kütüphanesi.
//!
//! `main.rs` (çalıştırılabilir sunucu) ve `tests/` (entegrasyon testleri) bu modülleri kullanır.
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod extract;
pub mod models;
pub mod routes;
pub mod services;
pub mod state;
+3 -24
View File
@@ -1,23 +1,8 @@
mod auth;
mod config;
mod db;
mod error;
mod extract;
mod models;
mod routes;
mod services;
mod state;
use std::{net::SocketAddr, process::ExitCode, sync::Arc, time::Duration};
use std::{net::SocketAddr, process::ExitCode};
use kakeibo::{config::Config, db, routes, state::AppState};
use tracing_subscriber::EnvFilter;
use crate::{
auth::{jwt::Jwt, limiter::LoginLimiter},
config::Config,
state::AppState,
};
#[tokio::main]
async fn main() -> ExitCode {
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
@@ -57,13 +42,7 @@ async fn main() -> ExitCode {
}
let port = config.port;
let state = AppState {
db,
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
config: Arc::new(config),
};
let state = AppState::new(db, config);
let app = routes::router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
-9
View File
@@ -36,15 +36,6 @@ pub fn scope(household_id: Uuid) -> Document {
doc! { "household_id": household_id, "deleted_at": null }
}
#[allow(dead_code)] // Faz 1 – Adım 5'te (kategori güncelleme) kullanılacak
/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1.
pub fn touch() -> Document {
doc! {
"$set": { "updated_at": now() },
"$inc": { "version": 1_i32 },
}
}
#[cfg(test)]
mod tests {
use super::*;
+39
View File
@@ -0,0 +1,39 @@
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Group {
/// 🏠 Zorunlu
Needs,
/// 🛍️ İstek
Wants,
/// 📚 Kültürel
Culture,
/// ⚡ Beklenmeyen
Unexpected,
}
impl Group {
pub const ALL: [Group; 4] = [
Group::Needs,
Group::Wants,
Group::Culture,
Group::Unexpected,
];
pub fn as_str(self) -> &'static str {
match self {
Group::Needs => "needs",
Group::Wants => "wants",
Group::Culture => "culture",
Group::Unexpected => "unexpected",
}
}
/// Ekranda gösterim sırası
pub fn order(self) -> usize {
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
}
}
+1
View File
@@ -2,6 +2,7 @@
//! Şema: `docs/DATA_MODEL.md`
pub mod common;
pub mod group;
pub mod household;
pub mod invite;
pub mod user;
+230
View File
@@ -0,0 +1,230 @@
//! Kategori ve ödeme türü uç noktaları (hanenin tüm üyeleri kullanabilir):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/categories?include_inactive=true` | listele |
//! | POST | `/api/categories` | ekle |
//! | PATCH | `/api/categories/{id}` | ad / emoji / aktiflik değiştir |
//! | PUT | `/api/categories/order` | sırala |
//!
//! `/api/payment-methods` aynı yapıdadır (yalnızca `group` alanı yoktur).
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::group::Group,
services::catalog::{self, Catalog, Changes, Item},
state::AppState,
};
// ---------- İstek / yanıt ----------
#[derive(Deserialize, Default)]
pub struct ListQuery {
#[serde(default)]
include_inactive: bool,
}
#[derive(Deserialize, Validate)]
pub struct CreateRequest {
group: Option<Group>,
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
name: String,
#[validate(length(max = 16, message = "Emoji çok uzun"))]
emoji: Option<String>,
}
#[derive(Deserialize, Validate)]
pub struct UpdateRequest {
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
name: Option<String>,
#[validate(length(max = 16, message = "Emoji çok uzun"))]
emoji: Option<String>,
is_active: Option<bool>,
version: Option<i32>,
}
#[derive(Deserialize, Validate)]
pub struct OrderRequest {
group: Option<Group>,
#[validate(length(max = 200, message = "Liste çok uzun"))]
ids: Vec<String>,
}
#[derive(Serialize)]
pub struct ItemDto {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<Group>,
name: String,
emoji: Option<String>,
sort_order: i32,
is_active: bool,
version: i32,
}
impl From<Item> for ItemDto {
fn from(i: Item) -> Self {
Self {
id: i.id.to_string(),
group: i.group,
name: i.name,
emoji: i.emoji,
sort_order: i.sort_order,
is_active: i.is_active,
version: i.version,
}
}
}
fn dtos(items: Vec<Item>) -> Json<Vec<ItemDto>> {
Json(items.into_iter().map(ItemDto::from).collect())
}
/// URL'deki kimlik bozuksa "bulunamadı" (başka hanenin kaydıyla aynı yanıt).
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
// ---------- Ortak işleyiciler ----------
async fn list(
c: Catalog,
state: AppState,
auth: AuthUser,
q: ListQuery,
) -> AppResult<Json<Vec<ItemDto>>> {
let items = catalog::list(&state.db, c, auth.household_id, q.include_inactive).await?;
Ok(dtos(items))
}
async fn create(
c: Catalog,
state: AppState,
auth: AuthUser,
req: CreateRequest,
) -> AppResult<(StatusCode, Json<ItemDto>)> {
let item = catalog::create(
&state.db,
c,
auth.household_id,
req.group,
req.name,
req.emoji,
)
.await?;
Ok((StatusCode::CREATED, Json(item.into())))
}
async fn update(
c: Catalog,
state: AppState,
auth: AuthUser,
id: String,
req: UpdateRequest,
) -> AppResult<Json<ItemDto>> {
let changes = Changes {
name: req.name,
emoji: req.emoji,
is_active: req.is_active,
version: req.version,
};
let item = catalog::update(&state.db, c, auth.household_id, parse_id(&id)?, changes).await?;
Ok(Json(item.into()))
}
async fn reorder(
c: Catalog,
state: AppState,
auth: AuthUser,
req: OrderRequest,
) -> AppResult<Json<Vec<ItemDto>>> {
let ids = req
.ids
.iter()
.map(Uuid::parse_str)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| AppError::BadRequest("Sıralama listesinde geçersiz kimlik var".into()))?;
let items = catalog::reorder(&state.db, c, auth.household_id, req.group, ids).await?;
Ok(dtos(items))
}
// ---------- Kategoriler ----------
pub async fn list_categories(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<ItemDto>>> {
list(Catalog::Categories, s, auth, q).await
}
pub async fn create_category(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<ItemDto>)> {
create(Catalog::Categories, s, auth, req).await
}
pub async fn update_category(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<ItemDto>> {
update(Catalog::Categories, s, auth, id, req).await
}
pub async fn reorder_categories(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<OrderRequest>,
) -> AppResult<Json<Vec<ItemDto>>> {
reorder(Catalog::Categories, s, auth, req).await
}
// ---------- Ödeme türleri ----------
pub async fn list_payment_methods(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<ItemDto>>> {
list(Catalog::PaymentMethods, s, auth, q).await
}
pub async fn create_payment_method(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<ItemDto>)> {
create(Catalog::PaymentMethods, s, auth, req).await
}
pub async fn update_payment_method(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<ItemDto>> {
update(Catalog::PaymentMethods, s, auth, id, req).await
}
pub async fn reorder_payment_methods(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<OrderRequest>,
) -> AppResult<Json<Vec<ItemDto>>> {
reorder(Catalog::PaymentMethods, s, auth, req).await
}
+22 -2
View File
@@ -1,16 +1,18 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod auth;
mod catalog;
mod health;
mod household;
use axum::{
Router,
routing::{delete, get, post},
routing::{delete, get, patch, post, put},
};
use crate::state::AppState;
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/health", get(health::health))
@@ -23,7 +25,25 @@ pub fn router(state: AppState) -> Router {
"/household/invites",
get(household::list_invites).post(household::create_invite),
)
.route("/household/invites/{id}", delete(household::revoke_invite));
.route("/household/invites/{id}", delete(household::revoke_invite))
.route(
"/categories",
get(catalog::list_categories).post(catalog::create_category),
)
.route("/categories/order", put(catalog::reorder_categories))
.route("/categories/{id}", patch(catalog::update_category))
.route(
"/payment-methods",
get(catalog::list_payment_methods).post(catalog::create_payment_method),
)
.route(
"/payment-methods/order",
put(catalog::reorder_payment_methods),
)
.route(
"/payment-methods/{id}",
patch(catalog::update_payment_method),
);
Router::new().nest("/api", api).with_state(state)
}
+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;
+13 -1
View File
@@ -1,6 +1,6 @@
//! Tüm route'ların paylaştığı uygulama durumu.
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
use mongodb::Database;
@@ -17,3 +17,15 @@ pub struct AppState {
pub jwt: Arc<Jwt>,
pub login_limiter: Arc<LoginLimiter>,
}
impl AppState {
pub fn new(db: Database, config: Config) -> Self {
Self {
db,
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
config: Arc::new(config),
}
}
}