648 lines
21 KiB
Rust
648 lines
21 KiB
Rust
use axum::{
|
|
extract::{Path, State},
|
|
http::{HeaderMap, 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()));
|
|
}
|
|
|
|
// seat_id / room_id cross-validation
|
|
match (slot.room_id, req.seat_id.as_ref()) {
|
|
(None, Some(_)) => {
|
|
return Err(AppError::BadRequest("seat_id provided but slot has no room".into()));
|
|
}
|
|
(Some(_), None) => {
|
|
return Err(AppError::BadRequest("seat required".into()));
|
|
}
|
|
(Some(room_id), Some(seat_id)) => {
|
|
let room = sqlx::query_as::<_, Room>(
|
|
"SELECT id, name, layout_json FROM rooms WHERE id = ?",
|
|
)
|
|
.bind(room_id)
|
|
.fetch_optional(&pool)
|
|
.await?;
|
|
|
|
let room_row = room.ok_or(AppError::NotFound)?;
|
|
let elements: Vec<LayoutElement> = serde_json::from_str(&room_row.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()));
|
|
}
|
|
}
|
|
(None, None) => {}
|
|
}
|
|
|
|
// 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,
|
|
}))
|
|
.expect("serializing static json shape is infallible")
|
|
.replace('"', "%22");
|
|
|
|
let cookie_val = format!(
|
|
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
|
|
identity_json
|
|
);
|
|
|
|
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
|
|
.map_err(|_| AppError::BadRequest("invalid cookie value".into()))?;
|
|
let mut response = Json(json!({"ok": true})).into_response();
|
|
response.headers_mut().insert(axum::http::header::SET_COOKIE, header_val);
|
|
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_admin_app, 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_admin_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 = 'admin@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");
|
|
}
|
|
}
|