feat(attendance): student check-in API with FCFS seat locking
This commit is contained in:
647
backend/src/routes/checkin.rs
Normal file
647
backend/src/routes/checkin.rs
Normal file
@@ -0,0 +1,647 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
models::{Attendance, LayoutElement, Room, Slot, Student},
|
||||
};
|
||||
|
||||
/// Parse a single cookie value from a raw `Cookie` header string.
|
||||
fn parse_cookie(cookie_header: &str, key: &str) -> Option<String> {
|
||||
for pair in cookie_header.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(rest) = pair.strip_prefix(key) {
|
||||
if rest.starts_with('=') {
|
||||
return Some(rest[1..].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// URL-decode a percent-encoded string (minimal: only %22 → `"` needed for our cookie).
|
||||
fn url_decode_minimal(s: &str) -> String {
|
||||
s.replace("%22", "\"")
|
||||
}
|
||||
|
||||
async fn get_checkin_info(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
// Look up slot by code
|
||||
let slot = sqlx::query_as::<_, Slot>(
|
||||
"SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code
|
||||
FROM slots WHERE code = ?",
|
||||
)
|
||||
.bind(&code)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Closed slots are inaccessible
|
||||
if slot.status == "closed" {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
// Load layout if room is set
|
||||
let layout: Option<Vec<LayoutElement>> = if let Some(room_id) = slot.room_id {
|
||||
let room = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||
.bind(room_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
if let Some(r) = room {
|
||||
let elements: Vec<LayoutElement> = serde_json::from_str(&r.layout_json)
|
||||
.unwrap_or_default();
|
||||
Some(elements)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load attendances for this slot
|
||||
let attendances = sqlx::query_as::<_, Attendance>(
|
||||
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?",
|
||||
)
|
||||
.bind(slot.id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
// Parse identity cookie to determine which attendance is "mine"
|
||||
let cookie_str = headers
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let my_student_id: Option<i64> = parse_cookie(cookie_str, "attendance_identity")
|
||||
.and_then(|raw| {
|
||||
let decoded = url_decode_minimal(&raw);
|
||||
serde_json::from_str::<Value>(&decoded).ok()
|
||||
})
|
||||
.and_then(|v| {
|
||||
// Only valid if the code matches
|
||||
if v["code"].as_str() == Some(&code) {
|
||||
v["student_id"].as_i64()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let attendance_json: Vec<Value> = attendances
|
||||
.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"seat_id": a.seat_id,
|
||||
"student_id": a.student_id,
|
||||
"is_mine": my_student_id == Some(a.student_id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"slot": {
|
||||
"id": slot.id,
|
||||
"session_id": slot.session_id,
|
||||
"room_id": slot.room_id,
|
||||
"tutor_id": slot.tutor_id,
|
||||
"start_time": slot.start_time,
|
||||
"end_time": slot.end_time,
|
||||
"status": slot.status,
|
||||
"code": slot.code,
|
||||
},
|
||||
"layout": layout,
|
||||
"attendances": attendance_json,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_checkin_students(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(code): Path<String>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
// Look up slot by code
|
||||
let slot = sqlx::query_as::<_, Slot>(
|
||||
"SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code
|
||||
FROM slots WHERE code = ?",
|
||||
)
|
||||
.bind(&code)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if slot.status == "closed" {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
// Get course_id from the session
|
||||
let (course_id,): (i64,) =
|
||||
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(slot.session_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
// Return only students enrolled in that course
|
||||
let students = sqlx::query_as::<_, Student>(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!(students)))
|
||||
}
|
||||
|
||||
async fn post_checkin(
|
||||
State(pool): State<SqlitePool>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<crate::models::CheckinRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
// Look up slot by code
|
||||
let slot = sqlx::query_as::<_, Slot>(
|
||||
"SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code
|
||||
FROM slots WHERE code = ?",
|
||||
)
|
||||
.bind(&req.code)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if slot.status == "closed" {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
if slot.status != "open" {
|
||||
return Err(AppError::Conflict("check-in not available".into()));
|
||||
}
|
||||
|
||||
// If room_id is set, seat_id is required
|
||||
if slot.room_id.is_some() && req.seat_id.is_none() {
|
||||
return Err(AppError::BadRequest("seat required".into()));
|
||||
}
|
||||
|
||||
// Validate seat_id against room layout
|
||||
if let Some(ref seat_id) = req.seat_id {
|
||||
if let Some(room_id) = slot.room_id {
|
||||
let room = sqlx::query_as::<_, Room>(
|
||||
"SELECT id, name, layout_json FROM rooms WHERE id = ?",
|
||||
)
|
||||
.bind(room_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
|
||||
if let Some(r) = room {
|
||||
let elements: Vec<LayoutElement> = serde_json::from_str(&r.layout_json)
|
||||
.unwrap_or_default();
|
||||
let valid = elements
|
||||
.iter()
|
||||
.any(|e| &e.id == seat_id && e.kind == "seat");
|
||||
if !valid {
|
||||
return Err(AppError::BadRequest("invalid seat".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie identity check
|
||||
let cookie_str = headers
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if let Some(raw) = parse_cookie(cookie_str, "attendance_identity") {
|
||||
let decoded = url_decode_minimal(&raw);
|
||||
if let Ok(identity) = serde_json::from_str::<Value>(&decoded) {
|
||||
if identity["code"].as_str() == Some(&req.code) {
|
||||
// Same slot — verify student_id matches
|
||||
if let Some(cookie_student_id) = identity["student_id"].as_i64() {
|
||||
if cookie_student_id != req.student_id {
|
||||
return Err(AppError::Conflict("identity mismatch".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction: delete old attendance for (slot_id, student_id), then insert new
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
|
||||
.bind(slot.id)
|
||||
.bind(req.student_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let insert_result = sqlx::query(
|
||||
"INSERT INTO attendances (slot_id, student_id, seat_id, checked_in_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(slot.id)
|
||||
.bind(req.student_id)
|
||||
.bind(&req.seat_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
match insert_result {
|
||||
Ok(_) => {}
|
||||
Err(sqlx::Error::Database(e)) if e.message().contains("UNIQUE") => {
|
||||
return Err(AppError::Conflict("seat taken".into()));
|
||||
}
|
||||
Err(e) => return Err(AppError::Db(e)),
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Build set-cookie header
|
||||
let identity_json = serde_json::to_string(&json!({
|
||||
"code": req.code,
|
||||
"student_id": req.student_id,
|
||||
}))
|
||||
.unwrap()
|
||||
.replace('"', "%22");
|
||||
|
||||
let cookie_val = format!(
|
||||
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
|
||||
identity_json
|
||||
);
|
||||
|
||||
let mut response = Json(json!({"ok": true})).into_response();
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie_val).unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new()
|
||||
.route("/api/checkin/{code}", get(get_checkin_info))
|
||||
.route("/api/checkin/{code}/students", get(get_checkin_students))
|
||||
.route("/api/checkin", post(post_checkin))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, get, patch_json, post_json};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Seeds a complete open slot with a room containing two seats (s1, s2).
|
||||
/// Returns (app, auth, code, slot_id, course_id, tutor_id).
|
||||
async fn seed_open_slot_with_room(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> (axum::Router, String, String, i64, i64, i64) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
|
||||
// Course
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/courses",
|
||||
&auth,
|
||||
json!({"name": "FP", "semester": "SS2026"}),
|
||||
)
|
||||
.await;
|
||||
let course_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// Tutor enrollment
|
||||
let (tutor_id,): (i64,) =
|
||||
sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?,?)")
|
||||
.bind(tutor_id)
|
||||
.bind(course_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Room with 2 seats
|
||||
let layout = json!([
|
||||
{"id": "s1", "label": "S1", "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0, "type": "seat"},
|
||||
{"id": "s2", "label": "S2", "x": 1.0, "y": 0.0, "width": 1.0, "height": 1.0, "type": "seat"}
|
||||
]);
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/rooms",
|
||||
&auth,
|
||||
json!({"name": "Room A", "layout": layout}),
|
||||
)
|
||||
.await;
|
||||
let room_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// Session
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/sessions",
|
||||
&auth,
|
||||
json!({"course_id": course_id, "week_nr": 1, "date": "2026-04-28"}),
|
||||
)
|
||||
.await;
|
||||
let session_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// Slot
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/slots",
|
||||
&auth,
|
||||
json!({
|
||||
"session_id": session_id,
|
||||
"room_id": room_id,
|
||||
"tutor_id": tutor_id,
|
||||
"start_time": "09:00",
|
||||
"end_time": "10:00"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let slot_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// Open the slot
|
||||
let (_, body) = patch_json(
|
||||
app.clone(),
|
||||
&format!("/api/admin/slots/{slot_id}/status"),
|
||||
&auth,
|
||||
json!({"status": "open"}),
|
||||
)
|
||||
.await;
|
||||
let code = serde_json::from_slice::<Value>(&body).unwrap()["code"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
(app, auth, code, slot_id, course_id, tutor_id)
|
||||
}
|
||||
|
||||
/// Insert a student in a course, return student_id.
|
||||
async fn insert_student(pool: &sqlx::SqlitePool, course_id: i64, name: &str) -> i64 {
|
||||
sqlx::query("INSERT INTO students (course_id, name) VALUES (?, ?)")
|
||||
.bind(course_id)
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn cannot_use_closed_slot_code(pool: sqlx::SqlitePool) {
|
||||
let (app, auth, code, slot_id, _course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
|
||||
// Close the slot
|
||||
let (status, _) = patch_json(
|
||||
app.clone(),
|
||||
&format!("/api/admin/slots/{slot_id}/status"),
|
||||
&auth,
|
||||
json!({"status": "closed"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// GET /api/checkin/{code} should now 404
|
||||
let (status, _) = get(app, &format!("/api/checkin/{code}"), "").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn locked_slot_still_accessible_read_only(pool: sqlx::SqlitePool) {
|
||||
let (app, auth, code, slot_id, _course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
|
||||
// Lock the slot
|
||||
let (status, _) = patch_json(
|
||||
app.clone(),
|
||||
&format!("/api/admin/slots/{slot_id}/status"),
|
||||
&auth,
|
||||
json!({"status": "locked"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// GET /api/checkin/{code} should still return 200
|
||||
let (status, _) = get(app, &format!("/api/checkin/{code}"), "").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn checkin_selects_seat(pool: sqlx::SqlitePool) {
|
||||
let (app, _auth, code, slot_id, course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
let student_id = insert_student(&pool, course_id, "Alice").await;
|
||||
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_id, "seat_id": "s1"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Verify attendance row exists
|
||||
let row: Option<(i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT student_id, seat_id FROM attendances WHERE slot_id = ? AND student_id = ?",
|
||||
)
|
||||
.bind(slot_id)
|
||||
.bind(student_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(row.is_some());
|
||||
assert_eq!(row.unwrap().1.as_deref(), Some("s1"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn second_student_cant_take_same_seat(pool: sqlx::SqlitePool) {
|
||||
let (app, _auth, code, _slot_id, course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
let student_a = insert_student(&pool, course_id, "Alice").await;
|
||||
let student_b = insert_student(&pool, course_id, "Bob").await;
|
||||
|
||||
// Student A takes s1
|
||||
let (status, _) = post_json(
|
||||
app.clone(),
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_a, "seat_id": "s1"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Student B tries s1 → 409
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_b, "seat_id": "s1"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn seat_change_frees_old_seat(pool: sqlx::SqlitePool) {
|
||||
let (app, _auth, code, slot_id, course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
let student_id = insert_student(&pool, course_id, "Alice").await;
|
||||
|
||||
// Take s1
|
||||
let (status, _) = post_json(
|
||||
app.clone(),
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_id, "seat_id": "s1"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Change to s2 (same student, same code — cookie check bypassed since no cookie set in test)
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_id, "seat_id": "s2"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// s1 must be freed (no row for student+slot+seat_id=s1)
|
||||
let s1_row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT id FROM attendances WHERE slot_id = ? AND student_id = ? AND seat_id = 's1'",
|
||||
)
|
||||
.bind(slot_id)
|
||||
.bind(student_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(s1_row.is_none(), "s1 should be freed");
|
||||
|
||||
// s2 must be taken
|
||||
let s2_row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT id FROM attendances WHERE slot_id = ? AND student_id = ? AND seat_id = 's2'",
|
||||
)
|
||||
.bind(slot_id)
|
||||
.bind(student_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(s2_row.is_some(), "s2 should be taken");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn checkin_on_locked_slot_rejected(pool: sqlx::SqlitePool) {
|
||||
let (app, auth, code, slot_id, course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
let student_id = insert_student(&pool, course_id, "Alice").await;
|
||||
|
||||
// Lock the slot
|
||||
let (status, _) = patch_json(
|
||||
app.clone(),
|
||||
&format!("/api/admin/slots/{slot_id}/status"),
|
||||
&auth,
|
||||
json!({"status": "locked"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// POST checkin → 409
|
||||
let (status, body) = post_json(
|
||||
app,
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_id, "seat_id": "s1"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["error"], "check-in not available");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn seat_required_when_room_set(pool: sqlx::SqlitePool) {
|
||||
let (app, _auth, code, _slot_id, course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
let student_id = insert_student(&pool, course_id, "Alice").await;
|
||||
|
||||
// No seat_id → 400
|
||||
let (status, body) = post_json(
|
||||
app,
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_id}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["error"].as_str().unwrap().contains("seat required"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn invalid_seat_id_rejected(pool: sqlx::SqlitePool) {
|
||||
let (app, _auth, code, _slot_id, course_id, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
let student_id = insert_student(&pool, course_id, "Alice").await;
|
||||
|
||||
// seat_id "s99" doesn't exist in layout → 400
|
||||
let (status, body) = post_json(
|
||||
app,
|
||||
"/api/checkin",
|
||||
"",
|
||||
json!({"code": code, "student_id": student_id, "seat_id": "s99"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["error"].as_str().unwrap().contains("invalid seat"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn students_from_other_course_not_in_dropdown(pool: sqlx::SqlitePool) {
|
||||
let (app, auth, code, _slot_id, course_id_a, _tutor_id) =
|
||||
seed_open_slot_with_room(&pool).await;
|
||||
|
||||
// Create a second course (no tutor needed for this test)
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/courses",
|
||||
&auth,
|
||||
json!({"name": "Other", "semester": "SS2026"}),
|
||||
)
|
||||
.await;
|
||||
let course_id_b = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
let student_a = insert_student(&pool, course_id_a, "Alice").await;
|
||||
let student_b = insert_student(&pool, course_id_b, "Bob").await;
|
||||
|
||||
let (status, body) = get(app, &format!("/api/checkin/{code}/students"), "").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let students: Value = serde_json::from_slice(&body).unwrap();
|
||||
let ids: Vec<i64> = students
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s["id"].as_i64().unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(ids.contains(&student_a), "course A student should be present");
|
||||
assert!(!ids.contains(&student_b), "course B student must not appear");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use sqlx::SqlitePool;
|
||||
use crate::error::AppError;
|
||||
|
||||
mod auth_routes;
|
||||
mod checkin;
|
||||
mod courses;
|
||||
mod rooms;
|
||||
mod sessions;
|
||||
@@ -11,6 +12,7 @@ mod sessions;
|
||||
pub fn build(pool: SqlitePool) -> Router {
|
||||
Router::new()
|
||||
.merge(auth_routes::router())
|
||||
.merge(checkin::router())
|
||||
.merge(courses::router())
|
||||
.merge(rooms::router())
|
||||
.merge(sessions::router())
|
||||
|
||||
Reference in New Issue
Block a user