fix(attendance): add empty layout/label validation and put_json test helper

This commit is contained in:
2026-04-28 03:18:56 +02:00
parent 84f5788363
commit fcf2787bcc
2 changed files with 74 additions and 15 deletions

View File

@@ -15,6 +15,10 @@ use crate::{
};
fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
if layout.is_empty() {
return Err(AppError::BadRequest("layout must contain at least one element".into()));
}
let valid_types = ["seat", "table", "gap", "door"];
let mut ids: HashSet<&str> = HashSet::new();
let mut seat_labels: HashSet<&str> = HashSet::new();
@@ -46,6 +50,9 @@ fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
}
// seat labels must be unique among seats
if elem.kind == "seat" {
if elem.label.is_empty() {
return Err(AppError::BadRequest("seat label must be non-empty".into()));
}
if !seat_labels.insert(elem.label.as_str()) {
return Err(AppError::BadRequest(format!(
"duplicate seat label: {}",
@@ -62,12 +69,12 @@ 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")
let rows = sqlx::query_as::<_, (i64, String)>("SELECT id, name 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}))
.map(|(id, name)| json!({"id": id, "name": name}))
.collect();
Ok(Json(result))
}
@@ -139,7 +146,7 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, get, post_json};
use crate::test_helpers::{build_test_app, get, post_json, put_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -252,22 +259,18 @@ mod tests {
.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);
let (status, _) = put_json(
app.clone(),
&format!("/api/admin/rooms/{id}/layout"),
&auth,
new_layout,
)
.await;
assert_eq!(status, StatusCode::OK);
// verify new layout has 2 seats
let (status, body) = get(app, &format!("/api/admin/rooms/{id}"), &auth).await;
@@ -282,4 +285,40 @@ mod tests {
let (status, _) = get(app, "/api/admin/rooms", "").await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn layout_validation_rejects_empty_layout(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool).await;
let (status, _) = post_json(
app,
"/api/admin/rooms",
&auth,
json!({"name":"Bad","layout":[]}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[sqlx::test(migrations = "./migrations")]
async fn layout_validation_rejects_empty_seat_label(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool).await;
let layout = json!([
{"id":"s1","label":"","x":0.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 get_room_not_found(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool).await;
let (status, _) = get(app, "/api/admin/rooms/999", &auth).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
}

View File

@@ -44,6 +44,26 @@ pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Va
(status, body)
}
/// PUT JSON body to the app (one-shot), returns (StatusCode, response body bytes).
pub async fn put_json(app: Router, uri: &str, auth: &str, body: serde_json::Value)
-> (StatusCode, bytes::Bytes)
{
let mut req = Request::builder()
.method("PUT")
.uri(uri)
.header("Content-Type", "application/json");
if !auth.is_empty() {
req = req.header("Authorization", auth);
}
let req = req
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
(status, body)
}
/// GET from the app (one-shot), returns (StatusCode, response body bytes).
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
let mut builder = Request::builder()