fixup
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
//! 帳戶控制器:`/api/accounts` 資源的 REST CRUD。
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::account::{Account, AccountInput};
|
||||
use crate::state::AppState;
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// 控制器層錯誤:統一轉為帶 JSON 錯誤體的 HTTP 響應。
|
||||
#[derive(Debug)]
|
||||
enum ApiError {
|
||||
/// 指定的資源不存在(404)
|
||||
NotFound,
|
||||
/// 唯一欄位衝突(409)
|
||||
Conflict,
|
||||
/// 輸入驗證失敗(400),內為可直接展示的錯誤訊息
|
||||
BadRequest(String),
|
||||
/// 存儲層錯誤(503 / 500)
|
||||
Storage(StorageError),
|
||||
}
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(err: StorageError) -> Self {
|
||||
if err.is_unique_violation() {
|
||||
ApiError::Conflict
|
||||
} else {
|
||||
ApiError::Storage(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, "帳戶不存在".to_string()),
|
||||
ApiError::Conflict => (
|
||||
StatusCode::CONFLICT,
|
||||
"username 或 email 已被其他帳戶使用".to_string(),
|
||||
),
|
||||
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
|
||||
ApiError::Storage(err) => {
|
||||
tracing::error!("帳戶存儲層錯誤:{err}");
|
||||
if matches!(err, StorageError::Pool(_)) {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"資料庫暫時不可用".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "內部錯誤".to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有帳戶
|
||||
async fn list(State(state): State<AppState>) -> Result<Json<Vec<Account>>, ApiError> {
|
||||
let accounts = storage::account::list(&state.db).await?;
|
||||
Ok(Json(accounts))
|
||||
}
|
||||
|
||||
/// 建立帳戶;輸入會先經 [`AccountInput::normalized`] 驗證與正規化
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AccountInput>,
|
||||
) -> Result<(StatusCode, Json<Account>), ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
let account = storage::account::create(&state.db, payload).await?;
|
||||
Ok((StatusCode::CREATED, Json(account)))
|
||||
}
|
||||
|
||||
/// 取得單一帳戶
|
||||
async fn show(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
storage::account::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// 更新帳戶的全部可編輯欄位(username / email / display_name / is_active)
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<AccountInput>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
storage::account::update(&state.db, id, payload)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// 刪除帳戶
|
||||
async fn remove(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
match storage::account::delete(&state.db, id).await? {
|
||||
true => Ok(StatusCode::NO_CONTENT),
|
||||
false => Err(ApiError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/accounts", get(list).post(create))
|
||||
.route("/api/accounts/{id}", get(show).put(update).delete(remove))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::testutil;
|
||||
|
||||
fn payload(username: &str, email: &str) -> AccountInput {
|
||||
AccountInput {
|
||||
username: username.into(),
|
||||
email: email.into(),
|
||||
display_name: None,
|
||||
is_active: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_crud_via_handlers() {
|
||||
let (pool, path) = testutil::test_pool("accounts-api").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: crate::session::SessionStore::new(),
|
||||
});
|
||||
|
||||
// 建立:輸入被 trim、display_name 默認同 username → 201
|
||||
let (status, Json(created)) =
|
||||
create(state.clone(), Json(payload(" alice ", "alice@example.com")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(created.username, "alice");
|
||||
assert_eq!(created.display_name, "alice");
|
||||
|
||||
// 密碼雜湊永不外洩到序列化結果
|
||||
let json = serde_json::to_value(&created).unwrap();
|
||||
assert!(json.get("password_hash").is_none());
|
||||
|
||||
// 列表包含新帳戶(另有初始化自動建立的 admin 在前)
|
||||
let Json(accounts) = list(state.clone()).await.unwrap();
|
||||
assert_eq!(accounts.len(), 2);
|
||||
assert_eq!(accounts[0].username, "admin");
|
||||
assert_eq!(accounts[1].id, created.id);
|
||||
|
||||
// username 重複 → 409;輸入驗證失敗 → 400
|
||||
let err = create(state.clone(), Json(payload("alice", "bob@example.com")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::CONFLICT);
|
||||
let err = create(state.clone(), Json(payload("", "x@example.com")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// 查詢:存在 / 不存在 → 200 / 404
|
||||
let Json(found) = show(state.clone(), Path(created.id)).await.unwrap();
|
||||
assert_eq!(found.id, created.id);
|
||||
let err = show(state.clone(), Path(9999)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// 更新:欄位被覆寫
|
||||
let Json(updated) = update(
|
||||
state.clone(),
|
||||
Path(created.id),
|
||||
Json(AccountInput {
|
||||
username: "alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
display_name: Some("Alice".into()),
|
||||
is_active: Some(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.display_name, "Alice");
|
||||
assert!(!updated.is_active);
|
||||
|
||||
// 刪除:→ 204,再刪 → 404
|
||||
assert_eq!(
|
||||
remove(state.clone(), Path(created.id)).await.unwrap(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let err = remove(state, Path(created.id)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user