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:
Generated
+2
@@ -1287,6 +1287,7 @@ dependencies = [
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"dotenvy",
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"mongodb",
|
||||
"serde",
|
||||
@@ -1294,6 +1295,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
|
||||
@@ -19,3 +19,7 @@ tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
uuid = { version = "1.29.2", features = ["v4", "v7"] }
|
||||
validator = { version = "0.21.0", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.5"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
|
||||
@@ -61,3 +61,69 @@ GET {{baseUrl}}/household/invites
|
||||
|
||||
### Daveti iptal et (yalnızca hane sahibi) — id'yi yukarıdaki listeden alın
|
||||
DELETE {{baseUrl}}/household/invites/BURAYA-ID
|
||||
|
||||
# ─────────────────────────── Kategoriler ───────────────────────────
|
||||
# Gruplar: needs (Zorunlu) · wants (İstek) · culture (Kültürel) · unexpected (Beklenmeyen)
|
||||
# Silme yok; "is_active": false ile pasife alınır.
|
||||
|
||||
### Kategoriler (aktif olanlar; pasifler için ?include_inactive=true)
|
||||
GET {{baseUrl}}/categories
|
||||
|
||||
### Kategori ekle (grubun sonuna eklenir)
|
||||
POST {{baseUrl}}/categories
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"group": "needs",
|
||||
"name": "Aidat",
|
||||
"emoji": "🏢"
|
||||
}
|
||||
|
||||
### Kategori düzenle / pasife al — "version": listede gördüğünüz sürüm (başkası değiştirdiyse 409)
|
||||
PATCH {{baseUrl}}/categories/BURAYA-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Site Aidatı",
|
||||
"is_active": true,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Grup içi sıralama — gruptaki TÜM kimlikler (pasifler dahil), istenen sırada
|
||||
PUT {{baseUrl}}/categories/order
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"group": "culture",
|
||||
"ids": ["ID-1", "ID-2", "ID-3"]
|
||||
}
|
||||
|
||||
# ─────────────────────────── Ödeme türleri ───────────────────────────
|
||||
|
||||
### Ödeme türleri
|
||||
GET {{baseUrl}}/payment-methods
|
||||
|
||||
### Ödeme türü ekle
|
||||
POST {{baseUrl}}/payment-methods
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Yemek Kartı",
|
||||
"emoji": "🍽️"
|
||||
}
|
||||
|
||||
### Ödeme türü düzenle / pasife al
|
||||
PATCH {{baseUrl}}/payment-methods/BURAYA-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"is_active": false
|
||||
}
|
||||
|
||||
### Ödeme türü sıralama — TÜM kimlikler
|
||||
PUT {{baseUrl}}/payment-methods/order
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": ["ID-1", "ID-2", "ID-3", "ID-4"]
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Şema: `docs/DATA_MODEL.md`
|
||||
|
||||
pub mod common;
|
||||
pub mod group;
|
||||
pub mod household;
|
||||
pub mod invite;
|
||||
pub mod user;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Kategori ve ödeme türü davranış testleri.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::TestApp;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn names(list: &Value) -> Vec<&str> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["name"].as_str().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_household_gets_default_categories_and_payment_methods() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Yeni", None).await;
|
||||
|
||||
let (_, cats) = app.get("/categories", &s).await;
|
||||
assert_eq!(cats.as_array().unwrap().len(), 13);
|
||||
// Grup sırası: needs → wants → culture → unexpected
|
||||
let groups: Vec<&str> = cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["group"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(groups.first(), Some(&"needs"));
|
||||
assert_eq!(groups.last(), Some(&"unexpected"));
|
||||
|
||||
let (_, pms) = app.get("/payment-methods", &s).await;
|
||||
assert_eq!(
|
||||
names(&pms),
|
||||
vec!["Nakit", "Kredi Kartı", "Banka Kartı", "Havale/EFT"]
|
||||
);
|
||||
assert!(
|
||||
pms[0].get("group").is_none(),
|
||||
"ödeme türünde group alanı olmamalı"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_validates_and_appends_to_end_of_group() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
|
||||
// Grup zorunlu
|
||||
let (st, _) = app
|
||||
.post("/categories", &s, json!({ "name": "Grupsuz" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// Boş ad
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&s,
|
||||
json!({ "group": "needs", "name": " " }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// Geçersiz grup
|
||||
let (st, _) = app
|
||||
.post("/categories", &s, json!({ "group": "luks", "name": "X" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// Aynı ad (büyük/küçük harf duyarsız) → 409
|
||||
let (st, body) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&s,
|
||||
json!({ "group": "needs", "name": "MARKET" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{body}");
|
||||
// Aynı ad başka grupta serbest
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&s,
|
||||
json!({ "group": "wants", "name": "Market" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
|
||||
// Yeni kayıt grubun sonuna eklenir
|
||||
let (st, cat) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&s,
|
||||
json!({ "group": "needs", "name": " Aidat ", "emoji": "🏢" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
assert_eq!(cat["name"], "Aidat");
|
||||
let (_, cats) = app.get("/categories", &s).await;
|
||||
let needs: Vec<&str> = cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["group"] == "needs")
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(needs.last(), Some(&"Aidat"));
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_deactivate_and_version_conflict() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let (_, pm) = app
|
||||
.post(
|
||||
"/payment-methods",
|
||||
&s,
|
||||
json!({ "name": "Yemek Kartı", "emoji": "🍽️" }),
|
||||
)
|
||||
.await;
|
||||
let path = format!("/payment-methods/{}", pm["id"].as_str().unwrap());
|
||||
assert_eq!(pm["version"], 1);
|
||||
|
||||
// Ad + emoji kaldırma, doğru sürümle
|
||||
let (st, upd) = app
|
||||
.patch(
|
||||
&path,
|
||||
&s,
|
||||
json!({ "name": "Multinet", "emoji": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(upd["name"], "Multinet");
|
||||
assert_eq!(upd["emoji"], Value::Null);
|
||||
assert_eq!(upd["version"], 2);
|
||||
|
||||
// Eski sürümle güncelleme → 409 (başkası değiştirmiş)
|
||||
let (st, _) = app
|
||||
.patch(&path, &s, json!({ "name": "Sodexo", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Pasife al: varsayılan listede görünmez, include_inactive ile görünür
|
||||
let (st, _) = app.patch(&path, &s, json!({ "is_active": false })).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let (_, active) = app.get("/payment-methods", &s).await;
|
||||
assert!(!names(&active).contains(&"Multinet"));
|
||||
let (_, all) = app.get("/payment-methods?include_inactive=true", &s).await;
|
||||
assert!(names(&all).contains(&"Multinet"));
|
||||
|
||||
// Pasif kayıtla aynı adda yeni kayıt açılamaz
|
||||
let (st, _) = app
|
||||
.post("/payment-methods", &s, json!({ "name": "multinet" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Olmayan / bozuk kimlik → 404
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
"/payment-methods/01a1cc59-0000-7000-8000-000000000000",
|
||||
&s,
|
||||
json!({ "name": "X" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.patch("/payment-methods/bozuk", &s, json!({ "name": "X" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reorder_requires_complete_list() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
|
||||
let (_, cats) = app.get("/categories", &s).await;
|
||||
let mut culture: Vec<String> = cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["group"] == "culture")
|
||||
.map(|c| c["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(culture.len(), 3);
|
||||
|
||||
// Eksik liste → 400
|
||||
let (st, _) = app
|
||||
.put(
|
||||
"/categories/order",
|
||||
&s,
|
||||
json!({ "group": "culture", "ids": &culture[..2] }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Ters çevir → yeni sıra
|
||||
culture.reverse();
|
||||
let (st, ordered) = app
|
||||
.put(
|
||||
"/categories/order",
|
||||
&s,
|
||||
json!({ "group": "culture", "ids": culture }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(names(&ordered), vec!["Sinema/Konser", "Eğitim", "Kitap"]);
|
||||
assert_eq!(ordered[0]["sort_order"], 10);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Entegrasyon testleri için yardımcılar.
|
||||
//!
|
||||
//! Her test kendi geçici veritabanını (`kakeibo_test_<uuid>`) kullanır ve sonunda siler.
|
||||
//! Gereken: yerel MongoDB çalışıyor olmalı (`npm run db:up`), `.env` içinde `MONGODB_URI`.
|
||||
|
||||
#![allow(dead_code)] // her test dosyası tüm yardımcıları kullanmaz
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use kakeibo::{config::Config, db, routes, state::AppState};
|
||||
use mongodb::Database;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub struct TestApp {
|
||||
router: Router,
|
||||
pub db: Database,
|
||||
}
|
||||
|
||||
/// Oturum açmış bir test kullanıcısı
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
pub cookie: String,
|
||||
pub household_id: String,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub async fn new() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
let uri = std::env::var("MONGODB_URI")
|
||||
.expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)");
|
||||
|
||||
let base = db::connect(&uri)
|
||||
.await
|
||||
.expect("MongoDB'ye bağlanılamadı — `npm run db:up` çalıştırın");
|
||||
let name = format!("kakeibo_test_{}", uuid::Uuid::new_v4().simple());
|
||||
let db = base.client().database(&name);
|
||||
db::prepare(&db).await.expect("test DB hazırlanamadı");
|
||||
|
||||
let config = Config {
|
||||
mongodb_uri: uri,
|
||||
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
|
||||
app_url: "http://localhost".into(),
|
||||
port: 0,
|
||||
};
|
||||
let router = routes::router(AppState::new(db.clone(), config));
|
||||
Self { router, db }
|
||||
}
|
||||
|
||||
/// Test sonunda geçici veritabanını siler.
|
||||
pub async fn cleanup(self) {
|
||||
self.db.drop().await.ok();
|
||||
}
|
||||
|
||||
/// İstek gönderir; (durum kodu, JSON gövde, set-cookie) döner.
|
||||
pub async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
cookie: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value, Option<String>) {
|
||||
let mut req = Request::builder().method(method).uri(format!("/api{path}"));
|
||||
if let Some(c) = cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.unwrap();
|
||||
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let set_cookie = res
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(';').next())
|
||||
.map(String::from);
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
(status, json, set_cookie)
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn post(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self
|
||||
.send(Method::POST, path, Some(&s.cookie), Some(body))
|
||||
.await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn patch(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self
|
||||
.send(Method::PATCH, path, Some(&s.cookie), Some(body))
|
||||
.await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn put(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self
|
||||
.send(Method::PUT, path, Some(&s.cookie), Some(body))
|
||||
.await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::DELETE, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
}
|
||||
|
||||
/// Yeni kullanıcı kaydeder (davet koduyla veya yeni hane olarak) ve oturumunu döner.
|
||||
pub async fn register(&self, name: &str, invite_code: Option<&str>) -> Session {
|
||||
let email = format!(
|
||||
"{}-{}@test.com",
|
||||
name.to_lowercase(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let (status, body, cookie) = self
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/register",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"email": email,
|
||||
"password": "test-sifre-123",
|
||||
"invite_code": invite_code,
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED, "kayıt başarısız: {body}");
|
||||
Session {
|
||||
cookie: cookie.expect("kayıtta cookie dönmedi"),
|
||||
household_id: body["household"]["id"].as_str().unwrap().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Hane izolasyonu testleri: bir hane, başka hanenin verisini göremez ve değiştiremez.
|
||||
//! (KAKEIBO_PROMPT.md §11 — "başka hanenin verisine erişim imkânsız olmalı")
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::{Method, StatusCode};
|
||||
use common::TestApp;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn ids(list: &Value) -> Vec<String> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn households_cannot_see_or_touch_each_other() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ayse", None).await;
|
||||
let b = app.register("Burak", None).await;
|
||||
assert_ne!(a.household_id, b.household_id);
|
||||
|
||||
// A'ya özel kategori ve ödeme türü
|
||||
let (st, cat) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&a,
|
||||
json!({ "group": "wants", "name": "A'nın gizli kategorisi" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED);
|
||||
let cat_id = cat["id"].as_str().unwrap();
|
||||
let (_, pm) = app
|
||||
.post("/payment-methods", &a, json!({ "name": "A'nın kartı" }))
|
||||
.await;
|
||||
let pm_id = pm["id"].as_str().unwrap();
|
||||
let (_, invite) = app.post("/household/invites", &a, json!({})).await;
|
||||
let invite_id = invite["id"].as_str().unwrap();
|
||||
|
||||
// --- Okuma: B, A'nın kayıtlarını görmez ---
|
||||
let (_, b_cats) = app.get("/categories?include_inactive=true", &b).await;
|
||||
assert!(!ids(&b_cats).contains(&cat_id.to_string()));
|
||||
let (_, b_pms) = app.get("/payment-methods?include_inactive=true", &b).await;
|
||||
assert!(!ids(&b_pms).contains(&pm_id.to_string()));
|
||||
let (_, b_hh) = app.get("/household", &b).await;
|
||||
assert_eq!(b_hh["id"], b.household_id.as_str());
|
||||
assert_eq!(b_hh["members"].as_array().unwrap().len(), 1);
|
||||
let (_, b_invites) = app.get("/household/invites", &b).await;
|
||||
assert_eq!(b_invites, json!([]));
|
||||
|
||||
// --- Yazma: B, A'nın kayıtlarını değiştiremez (404: varlığı bile sızmaz) ---
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{cat_id}"),
|
||||
&b,
|
||||
json!({ "name": "ele geçirildi" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/payment-methods/{pm_id}"),
|
||||
&b,
|
||||
json!({ "is_active": false }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app
|
||||
.delete(&format!("/household/invites/{invite_id}"), &b)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// B, kendi sıralama listesine A'nın kaydını sokamaz
|
||||
let mut b_wants: Vec<String> = b_cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["group"] == "wants")
|
||||
.map(|c| c["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
b_wants.push(cat_id.to_string());
|
||||
let (st, _) = app
|
||||
.put(
|
||||
"/categories/order",
|
||||
&b,
|
||||
json!({ "group": "wants", "ids": b_wants }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// --- A'nın verisi hiç değişmedi ---
|
||||
let (_, a_cats) = app.get("/categories", &a).await;
|
||||
let mine = a_cats
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["id"] == cat_id)
|
||||
.unwrap();
|
||||
assert_eq!(mine["name"], "A'nın gizli kategorisi");
|
||||
assert_eq!(mine["version"], 1);
|
||||
let (_, a_invites) = app.get("/household/invites", &a).await;
|
||||
assert_eq!(ids(&a_invites), vec![invite_id.to_string()]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_joins_via_invite_and_shares_household_data() {
|
||||
let app = TestApp::new().await;
|
||||
let owner = app.register("Sahip", None).await;
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let member = app
|
||||
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||
.await;
|
||||
|
||||
assert_eq!(member.household_id, owner.household_id);
|
||||
|
||||
// Üye, sahibin eklediği kategoriyi görür ve düzenleyebilir (ortak defter)
|
||||
let (_, cat) = app
|
||||
.post(
|
||||
"/categories",
|
||||
&owner,
|
||||
json!({ "group": "needs", "name": "Ortak" }),
|
||||
)
|
||||
.await;
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/categories/{}", cat["id"].as_str().unwrap()),
|
||||
&member,
|
||||
json!({ "emoji": "🤝" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
|
||||
// Ama davet oluşturamaz
|
||||
let (st, _) = app.post("/household/invites", &member, json!({})).await;
|
||||
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_endpoints_require_session() {
|
||||
let app = TestApp::new().await;
|
||||
let endpoints = [
|
||||
(Method::GET, "/auth/me"),
|
||||
(Method::GET, "/household"),
|
||||
(Method::GET, "/household/invites"),
|
||||
(Method::POST, "/household/invites"),
|
||||
(Method::GET, "/categories"),
|
||||
(Method::POST, "/categories"),
|
||||
(Method::PUT, "/categories/order"),
|
||||
(Method::GET, "/payment-methods"),
|
||||
(Method::POST, "/payment-methods"),
|
||||
];
|
||||
for (method, path) in endpoints {
|
||||
// Oturumsuz ve sahte cookie ile
|
||||
for cookie in [None, Some("kakeibo_session=sahte.token.degeri")] {
|
||||
let (st, _, _) = app
|
||||
.send(method.clone(), path, cookie, Some(json!({})))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::UNAUTHORIZED, "{method} {path} korumasız!");
|
||||
}
|
||||
}
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user