feat: rooms CRUD with layout validation

This commit is contained in:
2026-04-28 03:07:40 +02:00
parent 4aef2f70df
commit 5743808265
2 changed files with 271 additions and 0 deletions
+2
View File
@@ -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)
}
+269
View File
@@ -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);
}
}