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
+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)
}