feat: rooms CRUD with layout validation
This commit is contained in:
@@ -5,11 +5,13 @@ use crate::error::AppError;
|
||||
|
||||
mod auth_routes;
|
||||
mod courses;
|
||||
mod rooms;
|
||||
|
||||
pub fn build(pool: SqlitePool) -> Router {
|
||||
Router::new()
|
||||
.merge(auth_routes::router())
|
||||
.merge(courses::router())
|
||||
.merge(rooms::router())
|
||||
.with_state(pool)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
auth::TutorClaims,
|
||||
error::AppError,
|
||||
models::{CreateRoom, LayoutElement, Room},
|
||||
};
|
||||
|
||||
fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
|
||||
let valid_types = ["seat", "table", "gap", "door"];
|
||||
let mut ids: HashSet<&str> = HashSet::new();
|
||||
let mut seat_labels: HashSet<&str> = HashSet::new();
|
||||
|
||||
for elem in layout {
|
||||
// id must be non-empty
|
||||
if elem.id.is_empty() {
|
||||
return Err(AppError::BadRequest("element id must be non-empty".into()));
|
||||
}
|
||||
// all ids must be unique
|
||||
if !ids.insert(elem.id.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"duplicate element id: {}",
|
||||
elem.id
|
||||
)));
|
||||
}
|
||||
// type must be valid
|
||||
if !valid_types.contains(&elem.kind.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid element type '{}'; must be one of: seat, table, gap, door",
|
||||
elem.kind
|
||||
)));
|
||||
}
|
||||
// x, y, width, height must be >= 0.0
|
||||
if elem.x < 0.0 || elem.y < 0.0 || elem.width < 0.0 || elem.height < 0.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"x, y, width, height must all be >= 0.0".into(),
|
||||
));
|
||||
}
|
||||
// seat labels must be unique among seats
|
||||
if elem.kind == "seat" {
|
||||
if !seat_labels.insert(elem.label.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"duplicate seat label: {}",
|
||||
elem.label
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rooms(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let rows = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms ORDER BY id")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
let result: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| json!({"id": r.id, "name": r.name}))
|
||||
.collect();
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn create_room(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Json(req): Json<CreateRoom>,
|
||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||
validate_layout(&req.layout)?;
|
||||
let layout_json = serde_json::to_string(&req.layout)
|
||||
.map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?;
|
||||
let id = sqlx::query("INSERT INTO rooms (name, layout_json) VALUES (?, ?)")
|
||||
.bind(&req.name)
|
||||
.bind(&layout_json)
|
||||
.execute(&pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name}))))
|
||||
}
|
||||
|
||||
async fn get_room(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let row = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json).map_err(|e| {
|
||||
AppError::BadRequest(format!("layout parse error: {e}"))
|
||||
})?;
|
||||
Ok(Json(json!({"id": row.id, "name": row.name, "layout": layout})))
|
||||
}
|
||||
|
||||
async fn update_room_layout(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
Json(layout): Json<Vec<LayoutElement>>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
// verify room exists
|
||||
let _row = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
validate_layout(&layout)?;
|
||||
let layout_json = serde_json::to_string(&layout)
|
||||
.map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?;
|
||||
sqlx::query("UPDATE rooms SET layout_json = ? WHERE id = ?")
|
||||
.bind(&layout_json)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
Ok(Json(json!({"id": id})))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new()
|
||||
.route("/api/admin/rooms", get(list_rooms).post(create_room))
|
||||
.route("/api/admin/rooms/{id}", get(get_room))
|
||||
.route("/api/admin/rooms/{id}/layout", put(update_room_layout))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, get, post_json};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_room_with_layout(pool: sqlx::SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool).await;
|
||||
let layout = json!([
|
||||
{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"},
|
||||
{"id":"s2","label":"A2","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}
|
||||
]);
|
||||
let (status, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/rooms",
|
||||
&auth,
|
||||
json!({"name":"Room 101","layout":layout}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
let (status, body) = get(app, &format!("/api/admin/rooms/{id}"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let room = serde_json::from_slice::<Value>(&body).unwrap();
|
||||
assert_eq!(room["layout"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn layout_validation_rejects_duplicate_ids(pool: sqlx::SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool).await;
|
||||
let layout = json!([
|
||||
{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"},
|
||||
{"id":"s1","label":"A2","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}
|
||||
]);
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/admin/rooms",
|
||||
&auth,
|
||||
json!({"name":"Bad","layout":layout}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn layout_validation_rejects_invalid_type(pool: sqlx::SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool).await;
|
||||
let layout = json!([
|
||||
{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"window"}
|
||||
]);
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/admin/rooms",
|
||||
&auth,
|
||||
json!({"name":"Bad","layout":layout}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn layout_validation_rejects_duplicate_seat_labels(pool: sqlx::SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool).await;
|
||||
let layout = json!([
|
||||
{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"},
|
||||
{"id":"s2","label":"A1","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}
|
||||
]);
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/admin/rooms",
|
||||
&auth,
|
||||
json!({"name":"Bad","layout":layout}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn update_room_layout(pool: sqlx::SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool).await;
|
||||
// create room
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/rooms",
|
||||
&auth,
|
||||
json!({"name":"R1","layout":[
|
||||
{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}
|
||||
]}),
|
||||
)
|
||||
.await;
|
||||
let id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// update layout via PUT
|
||||
use axum::http::Request;
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
let new_layout = json!([
|
||||
{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"},
|
||||
{"id":"s2","label":"A2","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}
|
||||
]);
|
||||
let req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/admin/rooms/{id}/layout"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", &auth)
|
||||
.body(axum::body::Body::from(new_layout.to_string()))
|
||||
.unwrap();
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
// verify new layout has 2 seats
|
||||
let (status, body) = get(app, &format!("/api/admin/rooms/{id}"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let room = serde_json::from_slice::<Value>(&body).unwrap();
|
||||
assert_eq!(room["layout"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn rooms_requires_auth(pool: sqlx::SqlitePool) {
|
||||
let (app, _) = build_test_app(pool).await;
|
||||
let (status, _) = get(app, "/api/admin/rooms", "").await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user