Skip to main content

seattrellis_server/
server.rs

1//! Loopback-only HTTP backend for the SeatTrellis desktop app.
2//!
3//! Serves the compiled React workbench (`clients/web/dist`) and exposes the native
4//! endpoints the workbench's teacher flow needs end-to-end: roster upload &
5//! preview, class generation (which also creates an editable draft), the
6//! command-driven seating editor, export, and the static catalogs.
7//!
8//! The HTTP transport is axum/hyper/tokio (M1-04): [`crate::http`] adapts
9//! every request into the legacy [`Request`] shape and dispatches through
10//! [`route`], so the business layer and its tests are unchanged. Bounded
11//! concurrency, 64 MiB body limit (413) and graceful shutdown come from the
12//! maintained stack instead of a hand-rolled parser.
13//!
14//! Security posture (from-zero standards):
15//! - Binds loopback only (`127.0.0.1`); never exposes a LAN address.
16//! - No CORS headers are ever emitted; clients must already be same-origin.
17//! - Static files are confined to the configured web root; `..` traversal and
18//!   percent-encoded escapes are rejected, and canonical paths are re-checked.
19//! - Errors are coarse (`404 not found`) and never leak internal paths.
20//! - No unwrap/expect on the request path; all failures become HTTP errors.
21//! - Session/token/Host checks are the M1-05 milestone (not yet landed).
22
23use std::collections::HashMap;
24use std::fmt;
25use std::fs;
26use std::io;
27use std::net::{IpAddr, SocketAddr, TcpListener};
28use std::path::{Path, PathBuf};
29use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
30use std::sync::{Arc, Mutex};
31
32use serde_json::{json, Map, Value};
33
34use seattrellis_domain::editing::{self, EditorDraftStore};
35
36/// Compiled React workbench location resolved at build time. Used as a
37/// fallback so the binary serves assets regardless of the launch directory.
38const BUILTIN_WEB_STATIC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../clients/web/dist");
39/// Display path used when a release binary serves the compiled-in workbench.
40/// It deliberately does not point at a real filesystem directory.
41const EMBEDDED_WEB_STATIC: &str = "<embedded>/clients/web/dist";
42
43/// The solve-request store now lives in the application layer (M1-02);
44/// re-exported here so the transport keeps a single import path.
45pub(crate) use seattrellis_application::SolveRequestStore;
46
47/// Errors surfaced by [`resolve_web_root`] and [`Server::bind`].
48#[derive(Debug)]
49pub enum ServerError {
50    /// TCP listener could not be bound (e.g. port already in use).
51    Bind(io::Error),
52    /// No usable workbench build was found.
53    MissingWebRoot(String),
54}
55
56impl fmt::Display for ServerError {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            ServerError::Bind(e) => write!(f, "could not bind TCP listener: {e}"),
60            ServerError::MissingWebRoot(message) => f.write_str(message),
61        }
62    }
63}
64
65impl std::error::Error for ServerError {}
66
67/// Validated settings for the local backend.
68#[derive(Debug, Clone)]
69pub struct ServerConfig {
70    /// Loopback address to bind. Only `127.0.0.1`/`::1` are appropriate.
71    pub host: IpAddr,
72    /// TCP port to bind.
73    pub port: u16,
74    /// Directory containing `index.html` plus static assets.
75    pub web_root: PathBuf,
76    /// Root that typed (manually entered) paths resolve against. Requests
77    /// may never read outside this directory (PD-D14: manual paths are
78    /// trusted-root-relative only; absolute paths are rejected).
79    pub trusted_root: PathBuf,
80}
81
82impl ServerConfig {
83    pub fn new(port: u16, web_root: PathBuf) -> Self {
84        ServerConfig {
85            host: IpAddr::from([127, 0, 0, 1]),
86            port,
87            web_root,
88            trusted_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
89        }
90    }
91
92    /// Override the trusted root for typed file reads (defaults to the
93    /// process working directory). The Tauri shell pins this to its own
94    /// data directory; tests pin a temp directory.
95    pub fn with_trusted_root(mut self, root: PathBuf) -> Self {
96        self.trusted_root = root;
97        self
98    }
99}
100
101/// The running backend: a bound loopback listener plus the web root and the
102/// in-process stores shared across connection threads.
103pub struct Server {
104    listener: TcpListener,
105    local_addr: SocketAddr,
106    web_root: Arc<PathBuf>,
107    editor_store: Arc<EditorDraftStore>,
108    solve_requests: Arc<SolveRequestStore>,
109    /// Root that typed file-read paths resolve against (PD-D14 red line:
110    /// manual paths never leave this directory).
111    trusted_root: Arc<PathBuf>,
112    /// Set by the shell (Tauri exit) to stop the accept loop gracefully.
113    shutdown: Arc<AtomicBool>,
114    /// 256-bit random session token (M1-05). Required as `Bearer` on every
115    /// `/api/*` request; injected into the WebView memory by the shell.
116    session_token: String,
117}
118
119impl Server {
120    /// Bind the loopback listener. Fails if the address/port is unavailable.
121    pub fn bind(config: &ServerConfig) -> Result<Server, ServerError> {
122        let addr = SocketAddr::new(config.host, config.port);
123        let listener = TcpListener::bind(addr).map_err(ServerError::Bind)?;
124        let local_addr = listener.local_addr().map_err(ServerError::Bind)?;
125        Ok(Server {
126            listener,
127            local_addr,
128            web_root: Arc::new(config.web_root.clone()),
129            editor_store: Arc::new(editing::new_draft_store()),
130            solve_requests: Arc::new(Mutex::new(HashMap::new())),
131            trusted_root: Arc::new(config.trusted_root.clone()),
132            shutdown: Arc::new(AtomicBool::new(false)),
133            session_token: generate_session_token(),
134        })
135    }
136
137    /// The actual bound address (useful when port 0 auto-assigns).
138    pub fn addr(&self) -> SocketAddr {
139        self.local_addr
140    }
141
142    /// The 256-bit session token; shells inject it into the WebView memory.
143    pub fn session_token(&self) -> &str {
144        &self.session_token
145    }
146
147    /// Ask the accept loop to stop (used by the Tauri shell on exit).
148    pub fn request_shutdown(&self) {
149        self.shutdown.store(true, Ordering::Relaxed);
150    }
151
152    /// The shutdown flag, for shells that need to set it from a handler
153    /// without holding the `Server` (e.g. a Tauri exit callback).
154    pub fn shutdown_flag(&self) -> Arc<AtomicBool> {
155        Arc::clone(&self.shutdown)
156    }
157
158    /// Serve the loopback API until a shutdown signal arrives (Ctrl-C/SIGTERM
159    /// or [`Server::request_shutdown`]). Blocking facade over the tokio
160    /// runtime; axum/hyper handle connections (M1-04).
161    pub fn serve(&self) -> io::Result<()> {
162        let runtime = tokio::runtime::Builder::new_multi_thread()
163            .enable_all()
164            .build()?;
165        runtime.block_on(self.serve_async())
166    }
167
168    async fn serve_async(&self) -> io::Result<()> {
169        // The std listener was bound in blocking mode; tokio requires the
170        // non-blocking flag before registering the fd with the reactor.
171        let listener_std = self.listener.try_clone()?;
172        listener_std.set_nonblocking(true)?;
173        let listener = tokio::net::TcpListener::from_std(listener_std)?;
174        let state = crate::http::AppState {
175            web_root: Arc::clone(&self.web_root),
176            editor_store: Arc::clone(&self.editor_store),
177            solve_requests: Arc::clone(&self.solve_requests),
178            trusted_root: Arc::clone(&self.trusted_root),
179            shutdown: Arc::clone(&self.shutdown),
180            session_token: Arc::new(self.session_token.clone()),
181            bound_host: self.local_addr.ip().to_string(),
182            bound_port: self.local_addr.port(),
183        };
184        let router = crate::http::build_router(state);
185        axum::serve(listener, router)
186            .with_graceful_shutdown(crate::http::shutdown_signal(Arc::clone(&self.shutdown)))
187            .await
188    }
189}
190
191/// Generate the 256-bit loopback session token (32 CSPRNG bytes, hex).
192fn generate_session_token() -> String {
193    let mut bytes = [0u8; 32];
194    getrandom::fill(&mut bytes).expect("the OS entropy source must be available");
195    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
196}
197
198/// Locate a complete workbench build (`index.html` present) from, in order:
199/// 1. the `SEATTRELLIS_WEB_STATIC` env var,
200/// 2. the launch working directory (`clients/web/dist`, including app and
201///    Tauri-shell relative forms),
202/// 3. the workbench embedded at build time,
203/// 4. the compile-time path baked into the binary (a development fallback).
204pub fn resolve_web_root() -> Result<PathBuf, ServerError> {
205    let disk_candidates = [
206        std::env::var_os("SEATTRELLIS_WEB_STATIC").map(PathBuf::from),
207        Some(PathBuf::from("clients/web/dist")),
208        Some(PathBuf::from("../clients/web/dist")),
209        Some(PathBuf::from("../../clients/web/dist")),
210    ];
211
212    for candidate in disk_candidates.into_iter().flatten() {
213        if let Ok(resolved) = candidate.canonicalize() {
214            if resolved.join("index.html").is_file() {
215                return Ok(resolved);
216            }
217        }
218    }
219
220    if crate::embedded_web::has_index() {
221        return Ok(PathBuf::from(EMBEDDED_WEB_STATIC));
222    }
223
224    // This is only reachable for an unusual development build that omitted
225    // the generated asset manifest. Keep the source-tree fallback so the
226    // error remains actionable for contributors.
227    if let Ok(resolved) = Path::new(BUILTIN_WEB_STATIC).canonicalize() {
228        if resolved.join("index.html").is_file() {
229            return Ok(resolved);
230        }
231    }
232
233    Err(ServerError::MissingWebRoot(format!(
234        "no workbench build found under SEATTRELLIS_WEB_STATIC, the launch \
235         directory, or the built-in path {BUILTIN_WEB_STATIC:?}; build the \
236         React frontend first (clients/web/dist/index.html must exist)"
237    )))
238}
239
240// ---------------------------------------------------------------------------
241// Legacy request/response shapes
242// ---------------------------------------------------------------------------
243
244/// A parsed HTTP/1.1 request (head + body). The axum adapter (crate::http)
245/// fills this from the hyper request; the dispatcher and handlers consume it.
246pub(crate) struct Request {
247    pub(crate) method: String,
248    pub(crate) path: String,
249    /// The request's `Content-Type` header, if any (needed for multipart).
250    pub(crate) content_type: Option<String>,
251    pub(crate) body: Vec<u8>,
252}
253
254// ---------------------------------------------------------------------------
255// Routing and handlers
256// ---------------------------------------------------------------------------
257
258/// A minimal response: status code, optional content type, optional
259/// `Content-Disposition`, and the raw body.
260pub(crate) struct Response {
261    pub(crate) status: u16,
262    pub(crate) content_type: Option<&'static str>,
263    pub(crate) content_disposition: Option<String>,
264    pub(crate) body: Vec<u8>,
265}
266
267impl Response {
268    pub(crate) fn json(status: u16, value: serde_json::Value) -> Response {
269        let body = serde_json::to_vec(&value).unwrap_or_else(|_| b"{}".to_vec());
270        Response {
271            status,
272            content_type: Some("application/json; charset=utf-8"),
273            content_disposition: None,
274            body,
275        }
276    }
277
278    fn text(status: u16, content_type: &'static str, body: impl Into<Vec<u8>>) -> Response {
279        Response {
280            status,
281            content_type: Some(content_type),
282            content_disposition: None,
283            body: body.into(),
284        }
285    }
286}
287
288fn plain_response(status: u16, message: &str) -> Response {
289    Response::text(status, "text/plain; charset=utf-8", message.to_string())
290}
291
292fn json_error(status: u16, message: &str) -> Response {
293    Response::json(status, json!({ "error": message }))
294}
295
296/// Split a request path (query string already stripped) into segments.
297fn path_segments(path: &str) -> Vec<&str> {
298    path.trim_start_matches('/')
299        .split('/')
300        .filter(|segment| !segment.is_empty())
301        .collect()
302}
303
304/// Map an application-layer error onto an HTTP response (M1-02). The
305/// `invalid_solve_request` code carries the frozen SolveStatus (M1-03).
306fn app_error_response(error: seattrellis_application::AppError) -> Response {
307    let suggested_action = match error.status {
308        400 | 422 => "review_input",
309        404 => "choose_existing_resource",
310        _ => "retry_or_report",
311    };
312    let mut body = json!({
313        "error": error.code,
314        "code": error.code,
315        "message_key": format!("error.{}", error.code),
316        "message": error.message,
317        "relevant_entity": null,
318        "recoverable": error.status < 500,
319        "suggested_action": suggested_action,
320    });
321    if error.code == "invalid_solve_request" {
322        body["status"] = json!(seattrellis_core::classify_solve_error(
323            body["message"].as_str().unwrap_or_default(),
324        ));
325    }
326    Response::json(error.status, body)
327}
328
329/// `POST /api/v2/solve`: the stable, side-effect-free solver contract. All
330/// valid solver termination states are serialized as HTTP 200 domain results;
331/// only malformed/invalid requests and internal failures use HTTP errors.
332fn solve_v2_response(body: &[u8]) -> Response {
333    if body.is_empty() {
334        return app_error_response(seattrellis_application::AppError::solve_invalid_input(
335            "empty request body",
336        ));
337    }
338    let request: seattrellis_core::CoreSolveRequest = match serde_json::from_slice(body) {
339        Ok(request) => request,
340        Err(_) => {
341            return app_error_response(seattrellis_application::AppError::solve_invalid_input(
342                "request body is not a valid solve problem",
343            ))
344        }
345    };
346    match seattrellis_application::class_generation::solve_core(&request) {
347        Ok(outcome) => match serde_json::to_value(outcome) {
348            Ok(value) => Response::json(200, value),
349            Err(error) => app_error_response(seattrellis_application::AppError::internal(format!(
350                "could not serialize solve response: {error}"
351            ))),
352        },
353        Err(error) => app_error_response(error),
354    }
355}
356
357/// `POST /api/v1/classes/generate` (and `/api/v1/solve`): thin transport
358/// adapter - the orchestration lives in [`seattrellis_application::class_generation`].
359fn generate_response(
360    body: &[u8],
361    editor_store: &EditorDraftStore,
362    solve_requests: &SolveRequestStore,
363) -> Response {
364    if body.is_empty() {
365        return json_error(400, "empty request body");
366    }
367    let raw_request: Value = match serde_json::from_slice(body) {
368        Ok(value) => value,
369        Err(_) => return json_error(400, "request body is not valid JSON"),
370    };
371    match seattrellis_application::class_generation::generate_class(
372        &raw_request,
373        editor_store,
374        solve_requests,
375    ) {
376        Ok(outcome) => {
377            if !outcome.feasible {
378                return Response::json(
379                    200,
380                    json!({
381                        "status": outcome.status,
382                        "feasible": false,
383                        "class_name": outcome.class_name,
384                        "goal": {
385                            "goal_id": outcome.goal_id,
386                            "title": "日常轮换",
387                            "description": "兼顾视力和身高需求,减少近期重复邻座,并适度轮换位置。",
388                            "preset_name": null,
389                        },
390                        "warnings": [],
391                        "recommended_candidate_id": null,
392                        "candidates": [],
393                        "editor": null,
394                        "message_key": "solve.plan_not_found",
395                        "recoverable": true,
396                        "suggested_action": "review_constraints",
397                    }),
398                );
399            }
400
401            let Some(editor) = outcome.editor else {
402                return json_error(500, "solved result is missing its editable draft");
403            };
404            let candidates: Vec<Value> = outcome
405                .candidates
406                .iter()
407                .map(|candidate| {
408                    json!({
409                        "candidate_id": candidate.draft_id,
410                        "recommended": candidate.recommended,
411                        "total_score": candidate.total_score,
412                    })
413                })
414                .collect();
415            Response::json(
416                200,
417                json!({
418                    "status": outcome.status,
419                    "feasible": true,
420                    "class_name": outcome.class_name,
421                    "goal": {
422                        "goal_id": outcome.goal_id,
423                        "title": "日常轮换",
424                        "description": "兼顾视力和身高需求,减少近期重复邻座,并适度轮换位置。",
425                        "preset_name": null,
426                    },
427                    "warnings": [],
428                    "recommended_candidate_id": outcome.recommended_candidate_id,
429                    "candidates": candidates,
430                    "editor": editor,
431                }),
432            )
433        }
434        Err(error) => app_error_response(error),
435    }
436}
437
438/// `POST /api/v1/classes/rotation`: thin transport adapter - the
439/// orchestration lives in [`seattrellis_application::rotation`] (M2 parity,
440/// ledger A.1: the rotation-generation main flow the workbench depends on).
441fn rotation_generate_response(
442    body: &[u8],
443    editor_store: &EditorDraftStore,
444    solve_requests: &SolveRequestStore,
445) -> Response {
446    if body.is_empty() {
447        return json_error(400, "empty request body");
448    }
449    let raw_request: Value = match serde_json::from_slice(body) {
450        Ok(value) => value,
451        Err(_) => return json_error(400, "request body is not valid JSON"),
452    };
453    match seattrellis_application::rotation::generate_rotation_plan(
454        &raw_request,
455        editor_store,
456        solve_requests,
457    ) {
458        Ok(outcome) => {
459            if !outcome.feasible {
460                return Response::json(
461                    200,
462                    json!({
463                        "status": outcome.status,
464                        "feasible": false,
465                        "class_name": outcome.class_name,
466                        "warnings": outcome.warnings,
467                        "rotation_plan": null,
468                        "editor": null,
469                        "failed_period": outcome.failed_period,
470                        "message_key": "solve.rotation_plan_not_found",
471                        "recoverable": true,
472                        "suggested_action": "review_constraints",
473                    }),
474                );
475            }
476            let (Some(plan), Some(editor)) = (outcome.plan, outcome.editor) else {
477                return json_error(500, "solved rotation is missing its plan or editor");
478            };
479            Response::json(
480                200,
481                json!({
482                    "status": outcome.status,
483                    "feasible": true,
484                    "class_name": outcome.class_name,
485                    "warnings": outcome.warnings,
486                    "rotation_plan": plan,
487                    "editor": editor,
488                    "period_editors": outcome.period_editors.unwrap_or_default(),
489                    "failed_period": null,
490                }),
491            )
492        }
493        Err(error) => app_error_response(error),
494    }
495}
496
497/// `POST /api/v1/exports`: thin transport adapter - the orchestration lives
498/// in [`seattrellis_application::export`].
499fn export_response(
500    body: &[u8],
501    editor_store: &EditorDraftStore,
502    solve_requests: &SolveRequestStore,
503) -> Response {
504    if body.is_empty() {
505        return json_error(400, "empty request body");
506    }
507    let value: Value = match serde_json::from_slice(body) {
508        Ok(value) => value,
509        Err(_) => return json_error(400, "export request is not valid JSON"),
510    };
511    match seattrellis_application::export::export_draft(&value, editor_store, solve_requests) {
512        Ok(outcome) => Response {
513            status: 200,
514            content_type: Some(outcome.content_type),
515            content_disposition: Some(outcome.content_disposition),
516            body: outcome.body,
517        },
518        Err(error) => app_error_response(error),
519    }
520}
521
522/// Dispatch a parsed request to the matching handler.
523pub(crate) fn route(
524    request: &Request,
525    web_root: &Path,
526    editor_store: &EditorDraftStore,
527    solve_requests: &SolveRequestStore,
528    trusted_root: &Path,
529) -> Response {
530    // Split the query string off for routing: the raw path decides the route,
531    // and the query is handed to handlers that read it (e.g. `projects/recent`).
532    let (path, query) = match request.path.split_once('?') {
533        Some((path, query)) => (path, Some(query)),
534        None => (&request.path[..], None),
535    };
536    let segments = path_segments(path);
537
538    match (request.method.as_str(), segments.as_slice()) {
539        ("GET", ["api", "v1", "health"]) => health_response(),
540        ("GET", ["api", "v1", "catalogs"]) => catalogs_response(),
541        ("GET", ["api", "v1", "rules", "templates"]) => rules_templates_response(),
542        ("POST", ["api", "v1", "rules", "compile"]) => rules_compile_response(&request.body),
543        ("POST", ["api", "v1", "rules", "validate"]) => rules_validate_response(&request.body),
544        ("POST", ["api", "v2", "solve"]) => solve_v2_response(&request.body),
545        ("POST", ["api", "v1", "files", "read"]) => file_read_response(&request.body, trusted_root),
546        ("GET", ["api", "v1", "files", "root"]) => file_root_response(trusted_root),
547        ("POST", ["api", "v1", "classes", "generate"]) | ("POST", ["api", "v1", "solve"]) => {
548            generate_response(&request.body, editor_store, solve_requests)
549        }
550        ("POST", ["api", "v1", "classes", "rotation"]) => {
551            rotation_generate_response(&request.body, editor_store, solve_requests)
552        }
553        ("POST", ["api", "v1", "rosters", "drafts"]) => {
554            roster_upload_response(&request.body, request.content_type.as_deref())
555        }
556        ("GET", ["api", "v1", "rosters", "drafts", draft_id]) => roster_get_response(draft_id),
557        ("POST", ["api", "v1", "rosters", "drafts", draft_id, "preview"]) => {
558            roster_preview_response(draft_id, &request.body)
559        }
560        ("DELETE", ["api", "v1", "rosters", "drafts", draft_id]) => {
561            roster_delete_response(draft_id)
562        }
563        ("GET", ["api", "v1", "editing", "drafts", draft_id]) => {
564            editing_fetch_response(draft_id, editor_store)
565        }
566        ("GET", ["api", "v1", "editing", "drafts", draft_id, "audit"]) => {
567            draft_audit_response(draft_id, editor_store, solve_requests)
568        }
569        ("POST", ["api", "v1", "editing", "drafts", draft_id, "commands"]) => {
570            editing_command_response(draft_id, &request.body, editor_store, solve_requests)
571        }
572        ("POST", ["api", "v1", "exports"]) => {
573            export_response(&request.body, editor_store, solve_requests)
574        }
575        ("POST", ["api", "v1", "layouts", "drafts"]) => layout_create_response(&request.body),
576        ("GET", ["api", "v1", "layouts", "drafts", draft_id]) => layout_get_response(draft_id),
577        ("POST", ["api", "v1", "layouts", "drafts", draft_id, "commands"]) => {
578            layout_command_response(draft_id, &request.body)
579        }
580        ("GET", ["api", "v1", "layouts", "drafts", draft_id, "compiled"]) => {
581            layout_compiled_response(draft_id)
582        }
583        ("DELETE", ["api", "v1", "layouts", "drafts", draft_id]) => {
584            layout_delete_response(draft_id)
585        }
586        ("GET", ["api", "v1", "projects", "recent"]) => projects_recent_response(query),
587        ("POST", ["api", "v1", "projects", "history"]) => project_history_response(&request.body),
588        ("POST", ["api", "v1", "projects", "artifacts", "compare"]) => {
589            artifact_compare_response(&request.body)
590        }
591        ("POST", ["api", "v1", "projects", "artifacts", "restore"]) => {
592            artifact_restore_response(&request.body)
593        }
594        ("POST", ["api", "v1", "projects", "privacy"]) => project_privacy_response(&request.body),
595        ("POST", ["api", "v1", "projects", "bundle"]) => project_bundle_response(&request.body),
596        ("POST", ["api", "v1", "projects", "restore"]) => {
597            project_restore_response(&request.body, request.content_type.as_deref())
598        }
599        ("POST", ["api", "v1", "projects", "migration", "preview"]) => {
600            migration_preview_response(&request.body)
601        }
602        ("POST", ["api", "v1", "projects", "migration", "apply"]) => {
603            migration_apply_response(&request.body)
604        }
605        ("POST", ["api", "v1", "projects", "migration", "reference-checks"]) => {
606            migration_reference_checks_response(&request.body)
607        }
608        ("POST", ["api", "v1", "projects", "migration", "batch", "preview"]) => {
609            migration_batch_preview_response(&request.body)
610        }
611        ("POST", ["api", "v1", "projects", "migration", "batch", "apply"]) => {
612            migration_batch_apply_response(&request.body)
613        }
614        ("POST", ["api", "v1", "projects", "migration", "restore"]) => {
615            migration_restore_response(&request.body)
616        }
617        ("POST", ["api", "v1", "projects", "rotation", "save"]) => {
618            rotation_save_response(&request.body)
619        }
620        ("POST", ["api", "v1", "projects", "rotation", "load"]) => {
621            rotation_load_response(&request.body, editor_store)
622        }
623        ("POST", ["api", "v1", "projects", "rotation", "group-register"]) => {
624            rotation_register_download_response(&request.body)
625        }
626        ("POST", ["api", "v1", "projects", "rotation", "group-register", "preview"]) => {
627            rotation_register_preview_response(&request.body)
628        }
629        ("POST", ["api", "v1", "projects", "rotation", "group-register", "save"]) => {
630            rotation_register_save_response(&request.body, request.content_type.as_deref())
631        }
632        ("GET", []) | ("GET", ["index.html"]) => index_response(web_root),
633        ("GET", _) if path.starts_with("/api/") => json_error(404, "not found"),
634        ("GET", _) => static_response(web_root, path),
635        ("POST", _) => json_error(404, "not found"),
636        _ => plain_response(405, "method not allowed"),
637    }
638}
639
640fn health_response() -> Response {
641    Response::json(
642        200,
643        json!({
644            "status": "ok",
645            "service": "seattrellis",
646            "api_version": "1",
647        }),
648    )
649}
650
651/// `GET /api/v1/rules/templates`: the rule-builder sentence templates
652/// (M4 PD-D3). Slots carry their parameter bindings, so the workbench never
653/// compiles rules itself — it fills slots and posts them to `rules/compile`.
654fn rules_templates_response() -> Response {
655    let templates: Vec<Value> = seattrellis_rules::sentence_templates()
656        .into_iter()
657        .map(|template| serde_json::to_value(template).expect("template serializes"))
658        .collect();
659    Response::json(200, json!({ "api_version": "1", "templates": templates }))
660}
661
662/// `POST /api/v1/rules/compile`: fill a sentence template's slots and return
663/// the canonical rule entry (hard_rules / rules_overlay fragment). Errors are
664/// 422 with a structured code (missing_slot / invalid_choice / ...).
665fn rules_compile_response(body: &[u8]) -> Response {
666    if body.is_empty() {
667        return json_error(400, "empty request body");
668    }
669    let raw: Value = match serde_json::from_slice(body) {
670        Ok(value) => value,
671        Err(_) => return json_error(400, "request body is not valid JSON"),
672    };
673    let Some(template_id) = raw.get("template_id").and_then(Value::as_str) else {
674        return json_error(400, "compile requires a template_id");
675    };
676    let slots: Map<String, Value> = raw
677        .get("slots")
678        .and_then(Value::as_object)
679        .cloned()
680        .unwrap_or_default();
681    match seattrellis_rules::compile_sentence(template_id, &slots) {
682        Ok(compiled) => Response::json(
683            200,
684            json!({
685                "api_version": "1",
686                "category": compiled.category,
687                "rule_id": compiled.rule_id,
688                "entry": compiled.entry,
689            }),
690        ),
691        Err(error) => Response::json(
692            422,
693            json!({
694                "error": error.code,
695                "code": error.code,
696                "message": error.message,
697                "slot": error.slot,
698                "message_key": format!("error.{}", error.code),
699                "recoverable": true,
700                "suggested_action": "review_input",
701            }),
702        ),
703    }
704}
705
706/// `POST /api/v1/rules/validate`: validate a whole custom rules JSON document
707/// against the Rust rule registry (M6-02). Replaces the client-side rule
708/// validator the workbench used to run; Rust is the single source of truth for
709/// rule field taxonomy and shape. Returns the structured diagnostic list
710/// (`{ diagnostics: [{ path, code, detail? }] }`) the advanced settings view
711/// renders live.
712fn rules_validate_response(body: &[u8]) -> Response {
713    if body.is_empty() {
714        return json_error(400, "empty request body");
715    }
716    let raw: Value = match serde_json::from_slice(body) {
717        Ok(value) => value,
718        Err(_) => return json_error(400, "request body is not valid JSON"),
719    };
720    let Some(source) = raw.get("source").and_then(Value::as_str) else {
721        return json_error(400, "validate requires a source string");
722    };
723    let student_ids: Vec<String> = raw
724        .get("students")
725        .and_then(Value::as_array)
726        .map(|items| {
727            items
728                .iter()
729                .filter_map(Value::as_str)
730                .map(str::to_string)
731                .collect()
732        })
733        .unwrap_or_default();
734    let seat_ids: Vec<String> = raw
735        .get("seats")
736        .and_then(Value::as_array)
737        .map(|items| {
738            items
739                .iter()
740                .filter_map(Value::as_str)
741                .map(str::to_string)
742                .collect()
743        })
744        .unwrap_or_default();
745
746    let diagnostics = seattrellis_rules::validate_rule_document(source, &student_ids, &seat_ids);
747    Response::json(
748        200,
749        json!({
750            "api_version": "1",
751            "diagnostics": diagnostics,
752        }),
753    )
754}
755
756/// `GET /api/v1/catalogs`: static bilingual teacher catalogs, matching the
757/// workbench `CatalogResponse` contract. Only lists export formats the native
758/// renderer can actually produce.
759fn catalogs_response() -> Response {
760    Response::json(
761        200,
762        json!({
763            "roomTemplates": [
764                {
765                    "id": "standard-30",
766                    "name": localized("30 座教室", "30-seat classroom"),
767                    "description": localized(
768                        "5 排 × 6 座,中央过道,适合小班。",
769                        "5 rows of 6 seats with a center aisle for a smaller class."
770                    ),
771                    "rows": 5,
772                    "columns": 6,
773                },
774                {
775                    "id": "standard-48",
776                    "name": localized("48 座教室", "48-seat classroom"),
777                    "description": localized(
778                        "6 排 × 8 座,中央过道,适合常规班级。",
779                        "6 rows of 8 seats with a center aisle for a typical class."
780                    ),
781                    "rows": 6,
782                    "columns": 8,
783                },
784                {
785                    "id": "standard-60",
786                    "name": localized("60 座教室", "60-seat classroom"),
787                    "description": localized(
788                        "6 排 × 10 座,中央过道,适合大班。",
789                        "6 rows of 10 seats with a center aisle for a larger class."
790                    ),
791                    "rows": 6,
792                    "columns": 10,
793                },
794            ],
795            "teacherGoals": [
796                {
797                    "id": "daily-rotation",
798                    "name": localized("日常轮换", "Daily rotation"),
799                    "description": localized(
800                        "兼顾视力和身高需求,减少近期重复邻座,并适度轮换位置。",
801                        "Balance vision and height needs, vary recent neighbors, and rotate seats for everyday classroom use."
802                    ),
803                },
804                {
805                    "id": "quick-shuffle",
806                    "name": localized("快速打乱", "Quick shuffle"),
807                    "description": localized(
808                        "不依赖成绩或历史记录,快速生成一组中性的随机座位方案。",
809                        "Create a neutral shuffle without relying on scores or saved history."
810                    ),
811                },
812                {
813                    "id": "fair-shuffle",
814                    "name": localized("公平轮换", "Fair shuffle"),
815                    "description": localized(
816                        "优先参考历史座位,让每名学生逐步获得不同的位置和邻座。",
817                        "Use seating history to give each student a wider range of positions and neighbors over time."
818                    ),
819                },
820                {
821                    "id": "peer-support",
822                    "name": localized("邻座互助", "Peer support"),
823                    "description": localized(
824                        "让成绩层次不同的学生在邻座范围内适度混合。",
825                        "Mix students from different score ranges across neighboring seats."
826                    ),
827                },
828            ],
829            "exportFormats": [
830                {
831                    "id": "svg",
832                    "name": localized("SVG 矢量图", "SVG image"),
833                    "description": localized("矢量格式,方便继续编辑。", "Vector image that stays easy to edit."),
834                },
835                {
836                    "id": "png",
837                    "name": localized("PNG 图片", "PNG image"),
838                    "description": localized("适合截图和分享。", "A simple image for sharing."),
839                },
840                {
841                    "id": "pdf",
842                    "name": localized("PDF", "PDF"),
843                    "description": localized("适合打印或分发。", "Best for printing and sharing."),
844                },
845                {
846                    "id": "print-html",
847                    "name": localized("HTML / 打印版", "HTML / Print sheet"),
848                    "description": localized("可在浏览器查看,也适合 A4 打印或存为 PDF。", "View in a browser, print on A4, or save as PDF."),
849                },
850                {
851                    "id": "xlsx",
852                    "name": localized("Excel 表格", "Excel workbook"),
853                    "description": localized("含座位网格与名单两页,方便继续编辑。", "Seating grid plus an assignments sheet for further editing."),
854                },
855                {
856                    "id": "docx",
857                    "name": localized("Word 文档", "Word document"),
858                    "description": localized("带标题与座位表格的文档。", "A document with a title and a seat-grid table."),
859                },
860                {
861                    "id": "pptx",
862                    "name": localized("PPT 幻灯片", "PowerPoint slide"),
863                    "description": localized("单页 16:9 幻灯片,座位可单独编辑。", "One editable 16:9 slide with per-seat shapes."),
864                },
865            ],
866        }),
867    )
868}
869
870fn localized(zh: &str, en: &str) -> Value {
871    json!({ "zh-CN": zh, "en": en })
872}
873
874/// `POST /api/v1/classes/generate` (and `/api/v1/solve`): run the native
875/// cost-ranked greedy solver over the request body, then open an editable
876/// draft for the recommended plan so the workbench can adjust and export it.
877///
878/// Two request shapes are accepted:
879///
880/// 1. The raw `CoreSolveRequest` (`api_version` / `student_count` /
881///    `seat_positions` / ...), used by tests and advanced clients.
882/// 2. The React workbench's `GenerateClassRequest`
883///    (`draft.students` + `draft.room.template_id` + `draft.goal.goal_id`),
884///    detected by the presence of `draft.room.template_id` and expanded into
885///    a `CoreSolveRequest` via [`seattrellis_domain::room_templates::room_template_grid`]
886///    and [`seattrellis_domain::goal_rules::goal_rules`] before solving.
887///
888/// Returns the frontend `GenerateClassResponse` domain-result shape. A solved
889/// response includes candidates and an editor draft; `ProvenInfeasible`,
890/// `Timeout` and `Unknown` remain HTTP 200 results with no draft. An unknown
891/// room template or goal on the frontend path is a request error (`422`).
892/// parsed roster, returning the `RosterDraftResponse`.
893fn roster_upload_response(body: &[u8], content_type: Option<&str>) -> Response {
894    let Some(content_type) = content_type else {
895        return json_error(400, "multipart/form-data upload expected");
896    };
897    let Some(boundary) = multipart_boundary(content_type) else {
898        return json_error(400, "multipart/form-data boundary is missing");
899    };
900    let fields = match parse_multipart(body, &boundary) {
901        Ok(fields) => fields,
902        Err(message) => return json_error(422, &message),
903    };
904    let Some(file_bytes) = fields.get("file") else {
905        return json_error(422, "upload is missing a 'file' field");
906    };
907    if file_bytes.is_empty() {
908        return json_error(422, "uploaded roster file is empty");
909    }
910    match seattrellis_io::roster::upload_draft_json(file_bytes) {
911        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
912        Err(message) => json_error(422, &message),
913    }
914}
915
916/// Upper bound for a file read through the trusted-root endpoint. Roster
917/// files are small; the cap bounds memory on the loopback server.
918const MAX_TRUSTED_READ_BYTES: usize = 8 * 1024 * 1024;
919
920/// `POST /api/v1/files/read`: read a file the user pointed at, backing the
921/// manual-path entry of the D14 file picker (browser and desktop alike).
922///
923/// Security (PD-D14 red line, io-layer defense reused): the path must be a
924/// **relative** path inside the trusted root. Absolute paths, `..`
925/// traversal, NUL bytes and backslash separators are rejected outright; the
926/// canonical target must stay under the canonical root (defense in depth
927/// against symlink escape, same pattern as `safe_join`). The M1-05
928/// middleware (Bearer token, loopback Host/Origin) applies like every
929/// `/api/*` route.
930fn file_read_response(body: &[u8], trusted_root: &Path) -> Response {
931    if body.is_empty() {
932        return json_error(400, "empty request body");
933    }
934    let raw: Value = match serde_json::from_slice(body) {
935        Ok(value) => value,
936        Err(_) => return json_error(400, "request body is not valid JSON"),
937    };
938    let Some(rel_path) = raw.get("path").and_then(Value::as_str) else {
939        return json_error(400, "read requires a path");
940    };
941    let Some(joined) = trusted_relative_path(rel_path) else {
942        return json_error(400, "path must be a relative path inside the trusted root");
943    };
944    let candidate = trusted_root.join(joined);
945    let root_canonical = match trusted_root.canonicalize() {
946        Ok(path) => path,
947        Err(_) => return json_error(500, "trusted root is not readable"),
948    };
949    let candidate_canonical = match candidate.canonicalize() {
950        Ok(path) => path,
951        Err(_) => return json_error(404, "file was not found"),
952    };
953    if !candidate_canonical.starts_with(&root_canonical) {
954        return json_error(403, "path escapes the trusted root");
955    }
956    let metadata = match fs::metadata(&candidate_canonical) {
957        Ok(meta) => meta,
958        Err(_) => return json_error(404, "file was not found"),
959    };
960    if !metadata.is_file() {
961        return json_error(400, "path is not a file");
962    }
963    if metadata.len() > MAX_TRUSTED_READ_BYTES as u64 {
964        return json_error(413, "file is too large");
965    }
966    let bytes = match fs::read(&candidate_canonical) {
967        Ok(bytes) => bytes,
968        Err(_) => return json_error(404, "file was not found"),
969    };
970    let name = candidate_canonical
971        .file_name()
972        .and_then(|name| name.to_str())
973        .unwrap_or("file")
974        .to_string();
975    use base64::Engine as _;
976    let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
977    Response::json(
978        200,
979        json!({
980            "name": name,
981            "size": bytes.len(),
982            "content_base64": content_base64,
983        }),
984    )
985}
986
987/// `GET /api/v1/files/root`: expose the trusted root so the D14 path-input
988/// entry can tell the teacher what relative paths resolve against.
989fn file_root_response(trusted_root: &Path) -> Response {
990    let canonical = match trusted_root.canonicalize() {
991        Ok(path) => path,
992        Err(_) => return json_error(500, "trusted root is not readable"),
993    };
994    Response::json(200, json!({ "root": canonical.to_string_lossy() }))
995}
996
997/// Validate a manually typed path for the trusted-root reader.
998///
999/// Rules (PD-D14): non-empty; no NUL bytes; no backslash separators; must
1000/// be relative (no leading `/`, no `C:` drive prefix); `.`/`..` segments
1001/// are rejected outright. Returns the normalized `/`-joined relative path.
1002fn trusted_relative_path(raw: &str) -> Option<String> {
1003    if raw.is_empty() || raw.contains('\0') || raw.contains('\\') {
1004        return None;
1005    }
1006    if raw.starts_with('/') {
1007        return None;
1008    }
1009    let bytes = raw.as_bytes();
1010    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
1011        return None;
1012    }
1013    let mut segments = Vec::new();
1014    for segment in raw.split('/') {
1015        match segment {
1016            "" | "." => {}
1017            ".." => return None,
1018            segment => segments.push(segment),
1019        }
1020    }
1021    if segments.is_empty() {
1022        return None;
1023    }
1024    Some(segments.join("/"))
1025}
1026
1027fn roster_get_response(draft_id: &str) -> Response {
1028    match seattrellis_io::roster::get_draft_json(draft_id) {
1029        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1030        Err(_) => json_error(404, "roster draft was not found"),
1031    }
1032}
1033
1034fn roster_preview_response(draft_id: &str, body: &[u8]) -> Response {
1035    let body_str = match std::str::from_utf8(body) {
1036        Ok(text) => text,
1037        Err(_) => return json_error(400, "request body is not valid UTF-8"),
1038    };
1039    match seattrellis_io::roster::preview_update_json(draft_id, body_str) {
1040        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1041        Err(message) if message.contains("not found") => {
1042            json_error(404, "roster draft was not found")
1043        }
1044        Err(message) => json_error(400, &message),
1045    }
1046}
1047
1048fn roster_delete_response(draft_id: &str) -> Response {
1049    if seattrellis_io::roster::delete_draft(draft_id) {
1050        Response {
1051            status: 204,
1052            content_type: None,
1053            content_disposition: None,
1054            body: Vec::new(),
1055        }
1056    } else {
1057        json_error(404, "roster draft was not found")
1058    }
1059}
1060
1061fn editing_fetch_response(draft_id: &str, editor_store: &EditorDraftStore) -> Response {
1062    match editing::fetch_state(editor_store, draft_id) {
1063        Ok(state) => Response::json(200, serde_json::to_value(state).unwrap_or(json!({}))),
1064        Err(_) => json_error(404, "editor draft was not found"),
1065    }
1066}
1067
1068/// `GET /api/v1/editing/drafts/{id}/audit`: recompute the PlanScore
1069/// seven-dimension breakdown and hard-constraint audit for the draft's
1070/// current assignment (M5 B5/D5; shared with the diagnostics panel D6).
1071fn draft_audit_response(
1072    draft_id: &str,
1073    editor_store: &EditorDraftStore,
1074    solve_requests: &SolveRequestStore,
1075) -> Response {
1076    match seattrellis_application::draft_audit::audit_draft(editor_store, solve_requests, draft_id)
1077    {
1078        Ok(report) => Response::json(200, report),
1079        Err(error) => app_error_response(error),
1080    }
1081}
1082
1083/// `POST /api/v1/editing/drafts/{id}/commands`: apply a versioned editor
1084/// command. Maps domain errors to 400 (bad command), 404 (unknown draft), or
1085/// 409 (stale revision / protocol / duplicate / wrong-target conflicts).
1086fn editing_command_response(
1087    draft_id: &str,
1088    body: &[u8],
1089    editor_store: &EditorDraftStore,
1090    solve_requests: &SolveRequestStore,
1091) -> Response {
1092    let envelope: editing::EditorCommandEnvelope = match serde_json::from_slice(body) {
1093        Ok(envelope) => envelope,
1094        Err(_) => {
1095            return json_error(400, "command body is not a valid editor command envelope");
1096        }
1097    };
1098    if envelope.draft_id != draft_id {
1099        return json_error(409, "The editor command targets a different draft.");
1100    }
1101    match editing::apply_command_in_store(editor_store, &envelope) {
1102        Ok(state) => {
1103            let mut value = serde_json::to_value(&state).unwrap_or(json!({}));
1104            // Surface the hard-rule state of the resulting edit (plan §5.4):
1105            // an invalid intermediate edit stays an editable state, but it is
1106            // explicitly marked and cannot be exported as a solved plan.
1107            if let Ok(validation) = seattrellis_application::export::editor_validation_report(
1108                draft_id,
1109                &state,
1110                solve_requests,
1111            ) {
1112                if let Some(object) = value.as_object_mut() {
1113                    object.insert("validation".to_string(), validation);
1114                }
1115            }
1116            Response::json(200, value)
1117        }
1118        Err(message) => {
1119            let status = if message.contains("unknown editor draft") {
1120                404
1121            } else if message.contains("stale")
1122                || message.contains("protocol version")
1123                || message.contains("already been applied")
1124                || message.contains("different draft")
1125                || message.contains("command kind")
1126                || message.contains("command_id")
1127            {
1128                409
1129            } else {
1130                400
1131            };
1132            json_error(status, &message)
1133        }
1134    }
1135}
1136
1137// ---------------------------------------------------------------------------
1138// Layout routes
1139// ---------------------------------------------------------------------------
1140
1141/// `POST /api/v1/layouts/drafts`: create a layout draft from a
1142/// `CreateLayoutDraftRequest` JSON document and return the initial
1143/// `LayoutStateResponse`. Domain validation failures (missing name, multiple
1144/// sources, unknown template, oversized grid) are 422.
1145fn layout_create_response(body: &[u8]) -> Response {
1146    if body.is_empty() {
1147        return json_error(400, "empty request body");
1148    }
1149    let body_str = match std::str::from_utf8(body) {
1150        Ok(text) => text,
1151        Err(_) => return json_error(400, "request body is not valid UTF-8"),
1152    };
1153    match seattrellis_domain::layouts::create_layout_draft_json(body_str) {
1154        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1155        Err(message) if message.contains("poisoned") => json_error(500, &message),
1156        Err(message) => json_error(422, &message),
1157    }
1158}
1159
1160/// `GET /api/v1/layouts/drafts/{id}`: fetch the current layout state.
1161fn layout_get_response(draft_id: &str) -> Response {
1162    match seattrellis_domain::layouts::get_layout_state_json(draft_id) {
1163        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1164        Err(message) if message.contains("poisoned") => json_error(500, &message),
1165        Err(_) => json_error(404, "layout draft was not found"),
1166    }
1167}
1168
1169/// `POST /api/v1/layouts/drafts/{id}/commands`: dispatch a layout command.
1170/// Maps domain errors to 400 (bad command), 404 (unknown draft), or 409
1171/// (stale revision / duplicate / wrong-target conflicts).
1172fn layout_command_response(draft_id: &str, body: &[u8]) -> Response {
1173    let body_str = match std::str::from_utf8(body) {
1174        Ok(text) => text,
1175        Err(_) => return json_error(400, "command body is not valid UTF-8"),
1176    };
1177    match seattrellis_domain::layouts::dispatch_layout_command_json(draft_id, body_str) {
1178        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1179        Err(message) => {
1180            let status = if message.contains("poisoned") {
1181                500
1182            } else if message.contains("unknown layout draft") {
1183                404
1184            } else if message.contains("different draft")
1185                || message.contains("already been applied")
1186                || message.contains("stale revision")
1187                || message.contains("Unsupported layout command action")
1188            {
1189                409
1190            } else {
1191                400
1192            };
1193            json_error(status, &message)
1194        }
1195    }
1196}
1197
1198/// `GET /api/v1/layouts/drafts/{id}/compiled`: compile the draft into the
1199/// strict solver layout. Unknown drafts are 404; a draft that cannot compile
1200/// (e.g. no seats left) is 422.
1201fn layout_compiled_response(draft_id: &str) -> Response {
1202    match seattrellis_domain::layouts::compile_layout_draft_json(draft_id) {
1203        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1204        Err(message) if message.contains("poisoned") => json_error(500, &message),
1205        Err(message) if message.contains("unknown layout draft") => json_error(404, &message),
1206        Err(message) => json_error(422, &message),
1207    }
1208}
1209
1210/// `DELETE /api/v1/layouts/drafts/{id}`: remove a layout draft (204), or 404
1211/// when it never existed.
1212fn layout_delete_response(draft_id: &str) -> Response {
1213    if seattrellis_domain::layouts::delete_layout_draft(draft_id) {
1214        Response {
1215            status: 204,
1216            content_type: None,
1217            content_disposition: None,
1218            body: Vec::new(),
1219        }
1220    } else {
1221        json_error(404, "layout draft was not found")
1222    }
1223}
1224
1225// ---------------------------------------------------------------------------
1226// Project routes
1227// ---------------------------------------------------------------------------
1228
1229/// `GET /api/v1/projects/recent?root=..&limit=..`: list recent project files
1230/// under `root` (default `.`), capped at `limit` (default 20, 1..=100).
1231fn projects_recent_response(query: Option<&str>) -> Response {
1232    let params = parse_query(query.unwrap_or(""));
1233    let root = params
1234        .get("root")
1235        .cloned()
1236        .unwrap_or_else(|| ".".to_string());
1237    let limit = match params.get("limit") {
1238        None => 20,
1239        Some(raw) => match raw.parse::<usize>() {
1240            Ok(value) => value,
1241            Err(_) => return json_error(422, "The project list limit must be between 1 and 100."),
1242        },
1243    };
1244    match seattrellis_io::projects::list_projects_json(&root, limit) {
1245        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1246        Err(message) => json_error(422, &message),
1247    }
1248}
1249
1250/// Parse a URL query string into its percent-decoded key/value pairs.
1251fn parse_query(query: &str) -> HashMap<String, String> {
1252    let mut params = HashMap::new();
1253    for pair in query.split('&').filter(|pair| !pair.is_empty()) {
1254        let (key, value) = match pair.split_once('=') {
1255            Some((key, value)) => (key, value),
1256            None => (pair, ""),
1257        };
1258        let key = percent_decode(key).unwrap_or_else(|_| key.to_string());
1259        let value = percent_decode(value).unwrap_or_else(|_| value.to_string());
1260        params.insert(key, value);
1261    }
1262    params
1263}
1264
1265/// `POST /api/v1/projects/history`: return the history and outputs listing for
1266/// a project file (`{project_path, include_outputs?}`).
1267fn project_history_response(body: &[u8]) -> Response {
1268    let value = match parse_body_json(body) {
1269        Ok(value) => value,
1270        Err(response) => return response,
1271    };
1272    let project_path = match required_string(&value, "project_path") {
1273        Ok(value) => value,
1274        Err(response) => return response,
1275    };
1276    let project_path = resolve_request_path(&project_path);
1277    project_result_response(seattrellis_io::projects::project_history_json(
1278        &project_path,
1279    ))
1280}
1281
1282/// `POST /api/v1/projects/artifacts/compare`: compare two project artifacts
1283/// without returning student data (M2 parity, ledger A.2).
1284fn artifact_compare_response(body: &[u8]) -> Response {
1285    let value = match parse_body_json(body) {
1286        Ok(value) => value,
1287        Err(response) => return response,
1288    };
1289    let project_path = match required_string(&value, "project_path") {
1290        Ok(value) => value,
1291        Err(response) => return response,
1292    };
1293    let artifact_path = match required_string(&value, "artifact_path") {
1294        Ok(value) => value,
1295        Err(response) => return response,
1296    };
1297    let compare_to = match required_string(&value, "compare_to_path") {
1298        Ok(value) => value,
1299        Err(response) => return response,
1300    };
1301    let project_path = resolve_request_path(&project_path);
1302    let artifact_path = resolve_request_path(&artifact_path);
1303    let compare_to = resolve_request_path(&compare_to);
1304    project_result_response(seattrellis_io::projects::compare_artifacts_json(
1305        &project_path,
1306        &artifact_path,
1307        &compare_to,
1308    ))
1309}
1310
1311/// `POST /api/v1/projects/artifacts/restore`: restore an artifact as a new
1312/// output snapshot (M2 parity, ledger A.3).
1313fn artifact_restore_response(body: &[u8]) -> Response {
1314    let value = match parse_body_json(body) {
1315        Ok(value) => value,
1316        Err(response) => return response,
1317    };
1318    let project_path = match required_string(&value, "project_path") {
1319        Ok(value) => value,
1320        Err(response) => return response,
1321    };
1322    let artifact_path = match required_string(&value, "artifact_path") {
1323        Ok(value) => value,
1324        Err(response) => return response,
1325    };
1326    let project_path = resolve_request_path(&project_path);
1327    let artifact_path = resolve_request_path(&artifact_path);
1328    project_result_response(seattrellis_io::projects::restore_artifact_json(
1329        &project_path,
1330        &artifact_path,
1331    ))
1332}
1333
1334/// `POST /api/v1/projects/privacy`: scan a project for sensitive fields
1335/// (`{project_path, include_outputs?}`).
1336fn project_privacy_response(body: &[u8]) -> Response {
1337    let value = match parse_body_json(body) {
1338        Ok(value) => value,
1339        Err(response) => return response,
1340    };
1341    let project_path = match required_string(&value, "project_path") {
1342        Ok(value) => value,
1343        Err(response) => return response,
1344    };
1345    let project_path = resolve_request_path(&project_path);
1346    project_result_response(seattrellis_io::projects::project_privacy_json(
1347        &project_path,
1348    ))
1349}
1350
1351/// `POST /api/v1/projects/bundle`: pack a project into a self-contained
1352/// `.seattrellis.zip` byte stream for download. A successful pack is recorded
1353/// as a recently-opened project.
1354fn project_bundle_response(body: &[u8]) -> Response {
1355    let value = match parse_body_json(body) {
1356        Ok(value) => value,
1357        Err(response) => return response,
1358    };
1359    let project_path = match required_string(&value, "project_path") {
1360        Ok(value) => value,
1361        Err(response) => return response,
1362    };
1363    let project_path = resolve_request_path(&project_path);
1364    match seattrellis_io::projects::pack_project_json(&project_path) {
1365        Ok(bytes) => {
1366            record_recent(&project_path);
1367            let filename = seattrellis_io::projects::default_bundle_name(&project_path)
1368                .unwrap_or_else(|_| "project.seattrellis.zip".to_string());
1369            Response {
1370                status: 200,
1371                content_type: Some("application/zip"),
1372                content_disposition: Some(format!("attachment; filename=\"{filename}\"")),
1373                body: bytes,
1374            }
1375        }
1376        Err(message) => project_result_response(Err(message)),
1377    }
1378}
1379
1380/// `POST /api/v1/projects/restore`: restore a project from a multipart
1381/// `bundle` upload into `output_dir`, honoring the optional `overwrite` flag.
1382/// A successful restore is recorded as a recently-opened project.
1383fn project_restore_response(body: &[u8], content_type: Option<&str>) -> Response {
1384    let Some(content_type) = content_type else {
1385        return json_error(400, "multipart/form-data upload expected");
1386    };
1387    let Some(boundary) = multipart_boundary(content_type) else {
1388        return json_error(400, "multipart/form-data boundary is missing");
1389    };
1390    let fields = match parse_multipart(body, &boundary) {
1391        Ok(fields) => fields,
1392        Err(message) => return json_error(422, &message),
1393    };
1394    let Some(bundle) = fields.get("bundle") else {
1395        return json_error(422, "upload is missing a 'bundle' field");
1396    };
1397    if bundle.is_empty() {
1398        return json_error(422, "uploaded project bundle is empty");
1399    }
1400    let output_dir = match fields.get("output_dir") {
1401        Some(bytes) => match std::str::from_utf8(bytes) {
1402            Ok(value) if !value.trim().is_empty() => value.trim().to_string(),
1403            _ => return json_error(422, "Choose a destination folder for the restored project."),
1404        },
1405        None => return json_error(422, "Choose a destination folder for the restored project."),
1406    };
1407    let output_dir = resolve_request_path(&output_dir);
1408    let overwrite = fields
1409        .get("overwrite")
1410        .map(|bytes| std::str::from_utf8(bytes).unwrap_or("false"))
1411        .map(|value| {
1412            matches!(
1413                value.trim().to_ascii_lowercase().as_str(),
1414                "1" | "true" | "yes" | "on"
1415            )
1416        })
1417        .unwrap_or(false);
1418
1419    match seattrellis_io::projects::restore_project_bundle(bundle, &output_dir, overwrite) {
1420        Ok(project_path) => {
1421            let project_path_str = project_path.to_string_lossy().into_owned();
1422            record_recent(&project_path_str);
1423            let destination = project_path
1424                .parent()
1425                .map(Path::to_path_buf)
1426                .unwrap_or_else(|| PathBuf::from(&output_dir));
1427            Response::json(
1428                200,
1429                json!({
1430                    "api_version": "1",
1431                    "project_path": project_path_str,
1432                    "output_dir": destination.to_string_lossy(),
1433                }),
1434            )
1435        }
1436        Err(message) => json_error(422, &message),
1437    }
1438}
1439
1440/// Record a recently-accessed project under its display name.
1441fn record_recent(project_path: &str) {
1442    let name = project_recent_name(project_path);
1443    seattrellis_io::projects::record_recent_project(project_path, &name);
1444}
1445
1446/// A display name for a project file, derived from its filename stem.
1447fn project_recent_name(project_path: &str) -> String {
1448    let name = Path::new(project_path)
1449        .file_name()
1450        .map(|name| name.to_string_lossy().into_owned())
1451        .unwrap_or_default();
1452    for suffix in [".seattrellis.json", ".project.json", ".json"] {
1453        if let Some(stripped) = name.strip_suffix(suffix) {
1454            return stripped.to_string();
1455        }
1456    }
1457    name
1458}
1459
1460/// Map a projects-domain `Result<String, String>` onto a response, translating
1461/// domain error strings to HTTP status codes (404 for missing artifacts, 422
1462/// for validation problems, 500 for a poisoned store).
1463fn project_result_response(result: Result<String, String>) -> Response {
1464    match result {
1465        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1466        Err(message) if message.contains("poisoned") => json_error(500, &message),
1467        Err(message) if message.contains("not found") || message.contains("does not exist") => {
1468            json_error(404, &message)
1469        }
1470        Err(message) => json_error(422, &message),
1471    }
1472}
1473
1474/// Resolve a path from a request against the current working directory so the
1475/// project domain modules always receive an absolute reference. Absolute paths
1476/// pass through unchanged.
1477fn resolve_request_path(path: &str) -> String {
1478    let candidate = Path::new(path);
1479    if candidate.is_absolute() {
1480        return path.to_string();
1481    }
1482    match std::env::current_dir() {
1483        Ok(cwd) => cwd.join(candidate).to_string_lossy().into_owned(),
1484        Err(_) => path.to_string(),
1485    }
1486}
1487
1488// ---------------------------------------------------------------------------
1489// Migration routes
1490// ---------------------------------------------------------------------------
1491
1492/// `POST /api/v1/projects/migration/preview`: preview a migration of a project
1493/// artifact (`{project_path, artifact_path?, in_place?}`).
1494fn migration_preview_response(body: &[u8]) -> Response {
1495    let value = match parse_body_json(body) {
1496        Ok(value) => value,
1497        Err(response) => return response,
1498    };
1499    let project_path = match required_string(&value, "project_path") {
1500        Ok(value) => value,
1501        Err(response) => return response,
1502    };
1503    let project_path = resolve_request_path(&project_path);
1504    match seattrellis_io::migration::migration_preview_json(&project_path) {
1505        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1506        Err(message) => migration_error_response(&message),
1507    }
1508}
1509
1510/// `POST /api/v1/projects/migration/apply`: apply a migration to a project
1511/// artifact (`{project_path, artifact_path?, in_place?}`).
1512fn migration_apply_response(body: &[u8]) -> Response {
1513    let value = match parse_body_json(body) {
1514        Ok(value) => value,
1515        Err(response) => return response,
1516    };
1517    let project_path = match required_string(&value, "project_path") {
1518        Ok(value) => value,
1519        Err(response) => return response,
1520    };
1521    let in_place = match optional_bool(&value, "in_place") {
1522        Ok(value) => value,
1523        Err(response) => return response,
1524    };
1525    let project_path = resolve_request_path(&project_path);
1526    match seattrellis_io::migration::migration_apply_json(&project_path, in_place) {
1527        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1528        Err(message) => migration_error_response(&message),
1529    }
1530}
1531
1532/// `POST /api/v1/projects/migration/reference-checks`: report per-field
1533/// reference status for a project artifact. The workbench surfaces these inside
1534/// the migration preview; the standalone route keeps the underlying check
1535/// available to scripts and tests.
1536fn migration_reference_checks_response(body: &[u8]) -> Response {
1537    let value = match parse_body_json(body) {
1538        Ok(value) => value,
1539        Err(response) => return response,
1540    };
1541    let project_path = match required_string(&value, "project_path") {
1542        Ok(value) => value,
1543        Err(response) => return response,
1544    };
1545    let project_path = resolve_request_path(&project_path);
1546    match seattrellis_io::migration::migration_reference_checks_json(&project_path) {
1547        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1548        Err(message) => migration_error_response(&message),
1549    }
1550}
1551
1552/// `POST /api/v1/projects/migration/batch/preview`: preview migrations for a
1553/// set of project artifacts (`{project_paths, in_place?}`).
1554fn migration_batch_preview_response(body: &[u8]) -> Response {
1555    let value = match parse_body_json(body) {
1556        Ok(value) => value,
1557        Err(response) => return response,
1558    };
1559    let paths = match required_string_array(&value, "project_paths") {
1560        Ok(paths) => paths,
1561        Err(response) => return response,
1562    };
1563    let paths: Vec<String> = paths
1564        .iter()
1565        .map(|path| resolve_request_path(path))
1566        .collect();
1567    match seattrellis_io::migration::migration_batch_preview_json(&paths) {
1568        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1569        Err(message) => migration_error_response(&message),
1570    }
1571}
1572
1573/// `POST /api/v1/projects/migration/batch/apply`: apply migrations for a set of
1574/// project artifacts (`{project_paths, in_place?}`), rolling back on failure.
1575fn migration_batch_apply_response(body: &[u8]) -> Response {
1576    let value = match parse_body_json(body) {
1577        Ok(value) => value,
1578        Err(response) => return response,
1579    };
1580    let paths = match required_string_array(&value, "project_paths") {
1581        Ok(paths) => paths,
1582        Err(response) => return response,
1583    };
1584    let in_place = match optional_bool(&value, "in_place") {
1585        Ok(value) => value,
1586        Err(response) => return response,
1587    };
1588    let paths: Vec<String> = paths
1589        .iter()
1590        .map(|path| resolve_request_path(path))
1591        .collect();
1592    match seattrellis_io::migration::migration_batch_apply_json(&paths, in_place) {
1593        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1594        Err(message) => migration_error_response(&message),
1595    }
1596}
1597
1598/// `POST /api/v1/projects/migration/restore`: restore a migration backup over
1599/// its original artifact. The frontend sends `{project_path, source_path,
1600/// backup_path}`; the backup is restored onto `source_path`.
1601fn migration_restore_response(body: &[u8]) -> Response {
1602    let value = match parse_body_json(body) {
1603        Ok(value) => value,
1604        Err(response) => return response,
1605    };
1606    let backup_path = match required_string(&value, "backup_path") {
1607        Ok(value) => value,
1608        Err(response) => return response,
1609    };
1610    let source_path = match required_string(&value, "source_path") {
1611        Ok(value) => value,
1612        Err(response) => return response,
1613    };
1614    let backup_path = resolve_request_path(&backup_path);
1615    let source_path = resolve_request_path(&source_path);
1616    match seattrellis_io::migration::migration_restore_json(&backup_path, &source_path) {
1617        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1618        Err(message) => migration_error_response(&message),
1619    }
1620}
1621
1622/// Map a migration-domain error onto the matching HTTP status: 404 for a
1623/// missing artifact, 409 for a blocked batch, 422 for validation problems.
1624fn migration_error_response(message: &str) -> Response {
1625    if message.contains("poisoned") {
1626        json_error(500, message)
1627    } else if message.contains("does not exist") || message.contains("not found") {
1628        json_error(404, message)
1629    } else if message.contains("reference checks") {
1630        json_error(409, message)
1631    } else {
1632        json_error(422, message)
1633    }
1634}
1635
1636// ---------------------------------------------------------------------------
1637// Rotation routes
1638// ---------------------------------------------------------------------------
1639
1640/// `POST /api/v1/projects/rotation/save`: persist a rotation plan into the
1641/// project outputs (`{project_path, rotation_plan, draft_ids?, output_name?}`).
1642/// `draft_ids` / `output_name` are accepted for workbench compatibility; the
1643/// native module derives its artifact name from the outputs directory.
1644fn rotation_save_response(body: &[u8]) -> Response {
1645    let value = match parse_body_json(body) {
1646        Ok(value) => value,
1647        Err(response) => return response,
1648    };
1649    let project_path = match required_string(&value, "project_path") {
1650        Ok(value) => value,
1651        Err(response) => return response,
1652    };
1653    let Some(rotation_plan) = value.get("rotation_plan") else {
1654        return json_error(400, "request body is missing a 'rotation_plan' field");
1655    };
1656    let project_path = resolve_request_path(&project_path);
1657    let plan_json = rotation_plan.to_string();
1658    match seattrellis_io::rotation::rotation_save_json(&project_path, &plan_json) {
1659        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1660        Err(message) => rotation_error_response(&message),
1661    }
1662}
1663
1664/// `POST /api/v1/projects/rotation/load`: read the saved rotation plan back
1665/// (`{project_path, artifact_path?}`). `artifact_path` is accepted for
1666/// workbench compatibility; the module locates `rotation-plan.json` in the
1667/// project's outputs directory.
1668fn rotation_load_response(body: &[u8], editor_store: &EditorDraftStore) -> Response {
1669    let value = match parse_body_json(body) {
1670        Ok(value) => value,
1671        Err(response) => return response,
1672    };
1673    let project_path = match required_string(&value, "project_path") {
1674        Ok(value) => value,
1675        Err(response) => return response,
1676    };
1677    let project_path = resolve_request_path(&project_path);
1678    match seattrellis_io::rotation::rotation_load_plan(&project_path) {
1679        Ok((project_file, artifact_path, plan)) => {
1680            match rotation_load_drafts(&project_file, &artifact_path, &plan, editor_store) {
1681                Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1682                Err(message) => rotation_error_response(&message),
1683            }
1684        }
1685        Err(message) => rotation_error_response(&message),
1686    }
1687}
1688
1689/// Rebuild the editable per-period drafts for a saved rotation plan and
1690/// return the load envelope with `editor` (period 1) + `period_editors`
1691/// (every period, candidate_id "period-N"), mirroring the generate wiring
1692/// (M2 §5.7 / ledger §19.10: the workbench loads a period by matching
1693/// `candidate_id == "period-N"`).
1694fn rotation_load_drafts(
1695    project_file: &Path,
1696    artifact_path: &Path,
1697    plan: &Value,
1698    editor_store: &EditorDraftStore,
1699) -> Result<String, String> {
1700    let root = project_file
1701        .parent()
1702        .ok_or_else(|| "project file has no parent directory".to_string())?;
1703    let project: Value = serde_json::from_slice(
1704        &std::fs::read(project_file)
1705            .map_err(|error| format!("could not read project file: {error}"))?,
1706    )
1707    .map_err(|error| format!("could not parse project file: {error}"))?;
1708
1709    // Roster: project["students"] (default students.csv) next to the file.
1710    let students_rel = project
1711        .get("students")
1712        .and_then(Value::as_str)
1713        .unwrap_or("students.csv");
1714    let roster_bytes = std::fs::read(root.join(students_rel))
1715        .map_err(|error| format!("could not read roster: {error}"))?;
1716    let roster = seattrellis_io::roster::parse_roster_csv(&roster_bytes)?;
1717    let mut id_col = None;
1718    let mut name_col = None;
1719    for item in &roster.suggested_mapping {
1720        match item.field {
1721            seattrellis_io::roster::RosterField::StudentId => id_col = Some(item.column_index),
1722            seattrellis_io::roster::RosterField::Name => name_col = Some(item.column_index),
1723            _ => {}
1724        }
1725    }
1726    let id_col = id_col.ok_or_else(|| "roster has no student_id column".to_string())?;
1727    let mut keys: Vec<String> = Vec::new();
1728    let mut display_names = std::collections::HashMap::new();
1729    for row in &roster.rows {
1730        let id = row
1731            .cells
1732            .get(id_col)
1733            .filter(|cell| !cell.is_empty())
1734            .cloned();
1735        if let Some(id) = id {
1736            let name = name_col
1737                .and_then(|column| row.cells.get(column))
1738                .filter(|cell| !cell.is_empty())
1739                .cloned()
1740                .unwrap_or_else(|| id.clone());
1741            keys.push(id.clone());
1742            display_names.insert(id, name);
1743        }
1744    }
1745    let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
1746
1747    // Layout: project["layout"] (default layout.json) next to the file.
1748    let layout_rel = project
1749        .get("layout")
1750        .and_then(Value::as_str)
1751        .unwrap_or("layout.json");
1752    let layout_bytes = std::fs::read(root.join(layout_rel))
1753        .map_err(|error| format!("could not read layout: {error}"))?;
1754    #[derive(serde::Deserialize)]
1755    struct LayoutFile {
1756        seats: Vec<LayoutSeat>,
1757    }
1758    #[derive(serde::Deserialize)]
1759    struct LayoutSeat {
1760        seat_id: String,
1761        row: i64,
1762        col: i64,
1763        #[serde(default = "default_enabled")]
1764        enabled: bool,
1765    }
1766    fn default_enabled() -> bool {
1767        true
1768    }
1769    let layout: LayoutFile = serde_json::from_slice(&layout_bytes)
1770        .map_err(|error| format!("could not parse layout: {error}"))?;
1771    let seats: Vec<seattrellis_domain::editing::EditorSeatSpec> = layout
1772        .seats
1773        .iter()
1774        .map(|seat| seattrellis_domain::editing::EditorSeatSpec {
1775            seat_id: seat.seat_id.clone(),
1776            row: seat.row as i32,
1777            col: seat.col as i32,
1778            enabled: seat.enabled,
1779        })
1780        .collect();
1781
1782    // One validated draft per period, rebuilt from the saved snapshot.
1783    let periods = plan
1784        .get("periods")
1785        .and_then(Value::as_array)
1786        .ok_or_else(|| "rotation plan has no periods".to_string())?;
1787    let mut period_editors: Vec<Value> = Vec::with_capacity(periods.len());
1788    let mut first_editor: Option<Value> = None;
1789    for (index, period) in periods.iter().enumerate() {
1790        let period_number = period
1791            .get("period")
1792            .and_then(Value::as_i64)
1793            .unwrap_or(index as i64 + 1);
1794        let assignments = period
1795            .pointer("/snapshot/assignments")
1796            .and_then(Value::as_array)
1797            .ok_or_else(|| format!("period {period_number} snapshot has no assignments"))?;
1798        let pairs: Vec<(&str, &str)> = assignments
1799            .iter()
1800            .filter_map(|assignment| {
1801                Some((
1802                    assignment.get("student_key")?.as_str()?,
1803                    assignment.get("seat_id")?.as_str()?,
1804                ))
1805            })
1806            .collect();
1807        let candidate_id = format!("period-{period_number}");
1808        // Draft ids must be unique across loads: the store rejects a second
1809        // draft with an existing id, which used to make reloading the same
1810        // plan fail with "an editor draft already exists". The workbench
1811        // keeps matching on candidate_id ("period-N"); only the storage id
1812        // is freshly minted, using the same timestamp+sequence shape as the
1813        // application layer's `new_draft_id`.
1814        let draft_id = new_rebuilt_draft_id();
1815        let editor = seattrellis_domain::editing::create_draft(
1816            editor_store,
1817            draft_id,
1818            Some(candidate_id),
1819            &key_refs,
1820            seats.clone(),
1821            &pairs,
1822            Some(&display_names),
1823        )
1824        .map_err(|message| format!("could not rebuild period draft: {message}"))?;
1825        let editor_value = serde_json::to_value(editor)
1826            .map_err(|error| format!("could not serialize period draft: {error}"))?;
1827        if first_editor.is_none() {
1828            first_editor = Some(editor_value.clone());
1829        }
1830        period_editors.push(editor_value);
1831    }
1832    let editor =
1833        first_editor.ok_or_else(|| "rotation plan has no validated periods".to_string())?;
1834
1835    serde_json::to_string(&json!({
1836        "api_version": "1",
1837        "project_path": project_file.to_string_lossy(),
1838        "artifact_path": artifact_path.to_string_lossy(),
1839        "rotation_plan": plan,
1840        "editor": editor,
1841        "period_editors": period_editors,
1842    }))
1843    .map_err(|error| format!("could not encode rotation load response: {error}"))
1844}
1845
1846/// Sequence source for rebuilt draft ids (see [`new_rebuilt_draft_id`]).
1847static REBUILT_DRAFT_SEQ: AtomicU64 = AtomicU64::new(0);
1848
1849/// Mint a fresh editor draft id (`draft-<nanos hex><seq hex>`), mirroring the
1850/// application layer's `new_draft_id` so ids stay unique across repeated
1851/// loads of the same rotation plan and across generate flows.
1852fn new_rebuilt_draft_id() -> String {
1853    let nanos = std::time::SystemTime::now()
1854        .duration_since(std::time::UNIX_EPOCH)
1855        .map(|duration| duration.as_nanos())
1856        .unwrap_or(0);
1857    let seq = REBUILT_DRAFT_SEQ.fetch_add(1, Ordering::Relaxed);
1858    format!("draft-{nanos:x}{seq:x}")
1859}
1860
1861/// `POST /api/v1/projects/rotation/group-register`: render a printable HTML or
1862/// tabular CSV register for one rotation period. The workbench sends
1863/// `{project_path, artifact_path?, format, locale?}` and reads the bytes plus
1864/// the `Content-Disposition` filename. `period_index` selects the period
1865/// (default 1) because the native module renders one period at a time.
1866fn rotation_register_download_response(body: &[u8]) -> Response {
1867    let value = match parse_body_json(body) {
1868        Ok(value) => value,
1869        Err(response) => return response,
1870    };
1871    let project_path = match required_string(&value, "project_path") {
1872        Ok(value) => value,
1873        Err(response) => return response,
1874    };
1875    let period_index = match optional_i64(&value, "period_index") {
1876        Ok(value) => value.unwrap_or(1),
1877        Err(response) => return response,
1878    };
1879    let format_name = match optional_string(&value, "format") {
1880        Ok(value) => value.unwrap_or_else(|| "html".to_string()),
1881        Err(response) => return response,
1882    };
1883    let project_path = resolve_request_path(&project_path);
1884    if format_name.eq_ignore_ascii_case("csv") {
1885        match seattrellis_io::rotation::group_register_csv_json(&project_path, period_index) {
1886            Ok(bytes) => Response {
1887                status: 200,
1888                content_type: Some("text/csv; charset=utf-8"),
1889                content_disposition: Some(
1890                    "attachment; filename=\"group-register.csv\"".to_string(),
1891                ),
1892                body: bytes,
1893            },
1894            Err(message) => rotation_error_response(&message),
1895        }
1896    } else if format_name.eq_ignore_ascii_case("html") {
1897        match seattrellis_io::rotation::group_register_html_json(&project_path, period_index) {
1898            Ok(bytes) => Response {
1899                status: 200,
1900                content_type: Some("text/html; charset=utf-8"),
1901                content_disposition: Some(
1902                    "attachment; filename=\"group-register.html\"".to_string(),
1903                ),
1904                body: bytes,
1905            },
1906            Err(message) => rotation_error_response(&message),
1907        }
1908    } else {
1909        json_error(400, "request field 'format' must be \"html\" or \"csv\"")
1910    }
1911}
1912
1913/// `POST /api/v1/projects/rotation/group-register/preview`: summarize one
1914/// rotation period's membership grouped by seat row and column
1915/// (`{project_path, artifact_path?, period_index?}`).
1916fn rotation_register_preview_response(body: &[u8]) -> Response {
1917    let value = match parse_body_json(body) {
1918        Ok(value) => value,
1919        Err(response) => return response,
1920    };
1921    let project_path = match required_string(&value, "project_path") {
1922        Ok(value) => value,
1923        Err(response) => return response,
1924    };
1925    let period_index = match optional_i64(&value, "period_index") {
1926        Ok(value) => value.unwrap_or(1),
1927        Err(response) => return response,
1928    };
1929    let project_path = resolve_request_path(&project_path);
1930    match seattrellis_io::rotation::group_register_preview_json(&project_path, period_index) {
1931        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1932        Err(message) => rotation_error_response(&message),
1933    }
1934}
1935
1936/// `POST /api/v1/projects/rotation/group-register/save`: persist a group
1937/// register payload to the project outputs (`{project_path, groups}` or a
1938/// multipart form with `project_path` + `groups` fields). The groups payload
1939/// may be a JSON array or an object with a `groups` array.
1940fn rotation_register_save_response(body: &[u8], content_type: Option<&str>) -> Response {
1941    let is_multipart = content_type
1942        .map(|value| {
1943            value
1944                .to_ascii_lowercase()
1945                .starts_with("multipart/form-data")
1946        })
1947        .unwrap_or(false);
1948    if is_multipart {
1949        let Some(boundary) = content_type.and_then(multipart_boundary) else {
1950            return json_error(400, "multipart/form-data boundary is missing");
1951        };
1952        let fields = match parse_multipart(body, &boundary) {
1953            Ok(fields) => fields,
1954            Err(message) => return json_error(422, &message),
1955        };
1956        let project_path = match fields.get("project_path") {
1957            Some(bytes) => match std::str::from_utf8(bytes) {
1958                Ok(path) => path.to_string(),
1959                Err(_) => return json_error(400, "multipart 'project_path' is not valid UTF-8"),
1960            },
1961            None => return json_error(422, "upload is missing a 'project_path' field"),
1962        };
1963        let groups_json = match fields.get("groups") {
1964            Some(bytes) => match std::str::from_utf8(bytes) {
1965                Ok(json) => json.to_string(),
1966                Err(_) => return json_error(400, "multipart 'groups' is not valid UTF-8"),
1967            },
1968            None => return json_error(422, "upload is missing a 'groups' field"),
1969        };
1970        let project_path = resolve_request_path(&project_path);
1971        return match seattrellis_io::rotation::group_register_save_json(&project_path, &groups_json)
1972        {
1973            Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1974            Err(message) => rotation_error_response(&message),
1975        };
1976    }
1977    let value = match parse_body_json(body) {
1978        Ok(value) => value,
1979        Err(response) => return response,
1980    };
1981    let project_path = match required_string(&value, "project_path") {
1982        Ok(value) => value,
1983        Err(response) => return response,
1984    };
1985    let Some(groups) = value.get("groups").filter(|groups| !groups.is_null()) else {
1986        return json_error(400, "request body is missing a 'groups' field");
1987    };
1988    let project_path = resolve_request_path(&project_path);
1989    let groups_json = groups.to_string();
1990    match seattrellis_io::rotation::group_register_save_json(&project_path, &groups_json) {
1991        Ok(json) => Response::text(200, "application/json; charset=utf-8", json),
1992        Err(message) => rotation_error_response(&message),
1993    }
1994}
1995
1996/// Map a rotation-domain error onto the matching HTTP status: 404 for a
1997/// missing project file or rotation artifact (or an out-of-range period), 422
1998/// for validation problems.
1999fn rotation_error_response(message: &str) -> Response {
2000    if message.contains("poisoned") {
2001        json_error(500, message)
2002    } else if message.contains("not found")
2003        || message.contains("does not exist")
2004        || message.contains("out of range")
2005        || message.contains("No saved rotation plan")
2006    {
2007        json_error(404, message)
2008    } else {
2009        json_error(422, message)
2010    }
2011}
2012
2013/// Parse a JSON object request body, returning a 400 response on empty or
2014/// invalid JSON.
2015fn parse_body_json(body: &[u8]) -> Result<Value, Response> {
2016    if body.is_empty() {
2017        return Err(json_error(400, "empty request body"));
2018    }
2019    serde_json::from_slice(body).map_err(|_| json_error(400, "request body is not valid JSON"))
2020}
2021
2022/// Read a required string field from a parsed JSON object body.
2023fn required_string(value: &Value, field: &str) -> Result<String, Response> {
2024    value
2025        .get(field)
2026        .and_then(Value::as_str)
2027        .map(str::to_string)
2028        .ok_or_else(|| {
2029            json_error(
2030                400,
2031                &format!("request body is missing a '{field}' string field"),
2032            )
2033        })
2034}
2035
2036/// Read an optional boolean field from a parsed JSON object body (default
2037/// false when absent).
2038fn optional_bool(value: &Value, field: &str) -> Result<bool, Response> {
2039    match value.get(field) {
2040        None | Some(Value::Null) => Ok(false),
2041        Some(Value::Bool(value)) => Ok(*value),
2042        _ => Err(json_error(
2043            400,
2044            &format!("request field '{field}' must be a boolean"),
2045        )),
2046    }
2047}
2048
2049/// Read an optional string field from a parsed JSON object body (`None` when
2050/// absent).
2051fn optional_string(value: &Value, field: &str) -> Result<Option<String>, Response> {
2052    match value.get(field) {
2053        None | Some(Value::Null) => Ok(None),
2054        Some(Value::String(value)) => Ok(Some(value.clone())),
2055        _ => Err(json_error(
2056            400,
2057            &format!("request field '{field}' must be a string"),
2058        )),
2059    }
2060}
2061
2062/// Read an optional integer field from a parsed JSON object body (`None` when
2063/// absent).
2064fn optional_i64(value: &Value, field: &str) -> Result<Option<i64>, Response> {
2065    match value.get(field) {
2066        None | Some(Value::Null) => Ok(None),
2067        Some(value) => value
2068            .as_i64()
2069            .map(Some)
2070            .ok_or_else(|| json_error(400, &format!("request field '{field}' must be an integer"))),
2071    }
2072}
2073
2074/// Read a required array-of-strings field from a parsed JSON object body.
2075fn required_string_array(value: &Value, field: &str) -> Result<Vec<String>, Response> {
2076    let array = value.get(field).and_then(Value::as_array).ok_or_else(|| {
2077        json_error(
2078            400,
2079            &format!("request body is missing a '{field}' string array field"),
2080        )
2081    })?;
2082    array
2083        .iter()
2084        .map(|item| item.as_str().map(str::to_string))
2085        .collect::<Option<Vec<_>>>()
2086        .ok_or_else(|| {
2087            json_error(
2088                400,
2089                &format!("request field '{field}' must be an array of strings"),
2090            )
2091        })
2092}
2093
2094fn index_response(web_root: &Path) -> Response {
2095    match fs::read(web_root.join("index.html")) {
2096        Ok(bytes) => Response::text(200, "text/html; charset=utf-8", bytes),
2097        Err(_) => match crate::embedded_web::get("index.html") {
2098            Some(bytes) => Response::text(200, "text/html; charset=utf-8", bytes.to_vec()),
2099            None => plain_response(500, "workbench index.html is missing"),
2100        },
2101    }
2102}
2103
2104fn static_response(web_root: &Path, path: &str) -> Response {
2105    if let Some(target) = safe_join(web_root, path) {
2106        if let Ok(bytes) = fs::read(&target) {
2107            let content_type = content_type_for(&target);
2108            return Response::text(200, content_type, bytes);
2109        }
2110    }
2111
2112    let Some(asset_path) = normalized_asset_path(path) else {
2113        return plain_response(404, "not found");
2114    };
2115    match crate::embedded_web::get(&asset_path) {
2116        Some(bytes) => Response::text(
2117            200,
2118            content_type_for(Path::new(&asset_path)),
2119            bytes.to_vec(),
2120        ),
2121        None => plain_response(404, "not found"),
2122    }
2123}
2124
2125// ---------------------------------------------------------------------------
2126// Multipart parsing (minimal, dependency-free)
2127// ---------------------------------------------------------------------------
2128
2129/// Extract the `boundary` value from a `multipart/form-data` Content-Type.
2130fn multipart_boundary(content_type: &str) -> Option<String> {
2131    if !content_type
2132        .to_ascii_lowercase()
2133        .starts_with("multipart/form-data")
2134    {
2135        return None;
2136    }
2137    let boundary = content_type
2138        .split(';')
2139        .skip(1)
2140        .map(str::trim)
2141        .find(|part| part.to_ascii_lowercase().starts_with("boundary="))?
2142        .split_once('=')?
2143        .1
2144        .trim()
2145        .trim_matches('"')
2146        .trim();
2147    if boundary.is_empty() {
2148        None
2149    } else {
2150        Some(boundary.to_string())
2151    }
2152}
2153
2154/// Parse a `multipart/form-data` body into its fields (name -> raw bytes).
2155///
2156/// Handles the browser encoding exactly: parts are separated by
2157/// `--boundary` lines, each part carries `Content-Disposition` headers until a
2158/// blank line, the body is terminated by a final `--boundary--`. Works with
2159/// arbitrary (including randomly-generated) boundaries.
2160fn parse_multipart(body: &[u8], boundary: &str) -> Result<HashMap<String, Vec<u8>>, String> {
2161    let delimiter = format!("--{boundary}");
2162    let delimiter_bytes = delimiter.as_bytes();
2163    let mut fields: HashMap<String, Vec<u8>> = HashMap::new();
2164
2165    // The body should begin with the first delimiter; tolerate a few leading
2166    // bytes (e.g. stray CRLFs from a client).
2167    let start = find_sequence(body, 0, delimiter_bytes)
2168        .ok_or_else(|| "multipart body does not contain the boundary".to_string())?;
2169    let mut pos = start + delimiter_bytes.len();
2170
2171    loop {
2172        let rest = &body[pos..];
2173        if rest.starts_with(b"--") {
2174            break; // final `--boundary--`
2175        }
2176        if !rest.starts_with(b"\r\n") {
2177            return Err("malformed multipart boundary line".to_string());
2178        }
2179        pos += 2;
2180
2181        // Part headers run to the first blank line.
2182        let header_end = find_sequence(body, pos, b"\r\n\r\n")
2183            .ok_or_else(|| "multipart part is missing a header terminator".to_string())?;
2184        let header_block = std::str::from_utf8(&body[pos..header_end])
2185            .map_err(|_| "multipart part headers are not valid ASCII".to_string())?;
2186        pos = header_end + 4;
2187
2188        // Part content ends just before the next `\r\n--boundary`.
2189        let terminator = format!("\r\n{delimiter}");
2190        let content_end = find_sequence(body, pos, terminator.as_bytes())
2191            .ok_or_else(|| "multipart part content is not terminated".to_string())?;
2192        let content = &body[pos..content_end];
2193
2194        let name = part_header(header_block, "content-disposition")
2195            .and_then(|value| quoted_param(value, "name"))
2196            .ok_or_else(|| "multipart part is missing a content-disposition name".to_string())?;
2197        fields.insert(name, content.to_vec());
2198
2199        // Skip the `\r\n--boundary` that ended this part.
2200        pos = content_end + 2 + delimiter_bytes.len();
2201    }
2202
2203    Ok(fields)
2204}
2205
2206/// Read a header value (lowercased key) from a part's header block.
2207fn part_header<'a>(header_block: &'a str, key: &str) -> Option<&'a str> {
2208    header_block.split("\r\n").find_map(|line| {
2209        let (name, value) = line.split_once(':')?;
2210        if name.trim().eq_ignore_ascii_case(key) {
2211            Some(value.trim())
2212        } else {
2213            None
2214        }
2215    })
2216}
2217
2218/// Read `param="value"` (or `param=value`) from a header value such as
2219/// `form-data; name="file"; filename="roster.csv"`. Splitting on `;` prevents
2220/// `filename="..."` from being mistaken for a `name` parameter.
2221fn quoted_param(value: &str, param: &str) -> Option<String> {
2222    let prefix = format!("{param}=");
2223    value.split(';').map(str::trim).find_map(|segment| {
2224        let rest = segment.strip_prefix(&prefix)?;
2225        if let Some(stripped) = rest.strip_prefix('"') {
2226            let end = stripped.find('"')?;
2227            Some(stripped[..end].to_string())
2228        } else {
2229            Some(rest.split(';').next()?.trim().to_string())
2230        }
2231    })
2232}
2233
2234/// Byte-substring search from an offset.
2235fn find_sequence(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
2236    if needle.is_empty() || from > haystack.len() {
2237        return None;
2238    }
2239    haystack[from..]
2240        .windows(needle.len())
2241        .position(|window| window == needle)
2242        .map(|index| from + index)
2243}
2244
2245// ---------------------------------------------------------------------------
2246// Path safety helpers
2247// ---------------------------------------------------------------------------
2248
2249/// Resolve a request path inside `web_root`, rejecting any traversal.
2250///
2251/// - Percent-encoded characters are decoded first.
2252/// - NUL bytes and `..` path segments are rejected outright.
2253/// - As defense in depth, the canonicalised target must remain under the
2254///   canonicalised root.
2255fn safe_join(web_root: &Path, path: &str) -> Option<PathBuf> {
2256    let joined = normalized_asset_path(path)?;
2257    let candidate = web_root.join(joined);
2258
2259    let root_canonical = web_root.canonicalize().ok()?;
2260    let candidate_canonical = candidate.canonicalize().ok()?;
2261    if !candidate_canonical.starts_with(&root_canonical) {
2262        return None;
2263    }
2264    Some(candidate)
2265}
2266
2267/// Normalize a URL path for both filesystem and embedded-asset lookups.
2268/// Keeping this check shared prevents the embedded fallback from becoming a
2269/// weaker path than the development filesystem server.
2270fn normalized_asset_path(path: &str) -> Option<String> {
2271    let decoded = percent_decode(path).ok()?;
2272    if decoded.contains('\0') {
2273        return None;
2274    }
2275
2276    let mut segments = Vec::new();
2277    for segment in decoded.trim_start_matches('/').split('/') {
2278        match segment {
2279            "" | "." => {}
2280            ".." => return None,
2281            segment => segments.push(segment),
2282        }
2283    }
2284    let joined = segments.join("/");
2285    Some(if joined.is_empty() {
2286        "index.html".to_string()
2287    } else {
2288        joined
2289    })
2290}
2291
2292/// Minimal RFC 3986 percent-decoding (uppercase/lowercase hex).
2293fn percent_decode(input: &str) -> Result<String, ()> {
2294    let bytes = input.as_bytes();
2295    let mut out = Vec::with_capacity(bytes.len());
2296    let mut index = 0;
2297    while index < bytes.len() {
2298        match bytes[index] {
2299            b'%' => {
2300                if index + 2 >= bytes.len() {
2301                    return Err(());
2302                }
2303                let high = hex_value(bytes[index + 1]).ok_or(())?;
2304                let low = hex_value(bytes[index + 2]).ok_or(())?;
2305                out.push((high << 4) | low);
2306                index += 3;
2307            }
2308            byte => {
2309                out.push(byte);
2310                index += 1;
2311            }
2312        }
2313    }
2314    String::from_utf8(out).map_err(|_| ())
2315}
2316
2317fn hex_value(byte: u8) -> Option<u8> {
2318    match byte {
2319        b'0'..=b'9' => Some(byte - b'0'),
2320        b'a'..=b'f' => Some(byte - b'a' + 10),
2321        b'A'..=b'F' => Some(byte - b'A' + 10),
2322        _ => None,
2323    }
2324}
2325
2326/// Map a file extension to a content type for static assets.
2327fn content_type_for(path: &Path) -> &'static str {
2328    let ext = path
2329        .extension()
2330        .and_then(|value| value.to_str())
2331        .map(|value| value.to_ascii_lowercase());
2332    match ext.as_deref() {
2333        Some("html") => "text/html; charset=utf-8",
2334        Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
2335        Some("css") => "text/css; charset=utf-8",
2336        Some("map") | Some("json") => "application/json; charset=utf-8",
2337        Some("svg") => "image/svg+xml",
2338        Some("png") => "image/png",
2339        Some("jpg") | Some("jpeg") => "image/jpeg",
2340        Some("gif") => "image/gif",
2341        Some("ico") => "image/x-icon",
2342        Some("woff2") => "font/woff2",
2343        Some("woff") => "font/woff",
2344        Some("ttf") => "font/ttf",
2345        Some("wasm") => "application/wasm",
2346        Some("webmanifest") => "application/manifest+json",
2347        Some("txt") => "text/plain; charset=utf-8",
2348        _ => "application/octet-stream",
2349    }
2350}
2351
2352// ---------------------------------------------------------------------------
2353// Tests
2354// ---------------------------------------------------------------------------
2355
2356#[cfg(test)]
2357mod tests {
2358    use super::*;
2359    use base64::Engine as _;
2360    use std::sync::atomic::{AtomicU32, Ordering};
2361
2362    static TEST_DIR_SEQ: AtomicU32 = AtomicU32::new(0);
2363
2364    fn test_web_root() -> PathBuf {
2365        let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
2366        let dir = std::env::temp_dir().join(format!(
2367            "seattrellis_app_test_{}_{}",
2368            std::process::id(),
2369            seq
2370        ));
2371        let _ = fs::remove_dir_all(&dir);
2372        fs::create_dir_all(dir.join("assets")).unwrap();
2373        fs::write(dir.join("index.html"), "<html>test workbench</html>").unwrap();
2374        fs::write(dir.join("assets/app.js"), "console.log('hi');").unwrap();
2375        dir
2376    }
2377
2378    /// A fresh editor store + solve-request store for one route call. Roster
2379    /// drafts live in a process-global store (see `roster.rs`), so those tests
2380    /// use the returned draft ids directly.
2381    fn route_one(request: &Request, root: &Path) -> Response {
2382        route_one_with_root(request, root, root)
2383    }
2384
2385    /// Like [`route_one`] but with a caller-controlled trusted root, for the
2386    /// PD-D14 typed-file-read tests (web root and trusted root differ).
2387    fn route_one_with_root(request: &Request, root: &Path, trusted_root: &Path) -> Response {
2388        let editor_store = editing::new_draft_store();
2389        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
2390        route(request, root, &editor_store, &solve_requests, trusted_root)
2391    }
2392
2393    /// Route with a caller-owned editor + solve-request store, so tests can
2394    /// chain stateful requests (generate -> edit -> audit) like a client.
2395    fn route_with_store(
2396        request: &Request,
2397        root: &Path,
2398        editor_store: &EditorDraftStore,
2399        solve_requests: &SolveRequestStore,
2400    ) -> Response {
2401        route(request, root, editor_store, solve_requests, root)
2402    }
2403
2404    fn request(method: &str, path: &str, body: &[u8]) -> Request {
2405        request_with_content_type(method, path, body, None)
2406    }
2407
2408    fn request_with_content_type(
2409        method: &str,
2410        path: &str,
2411        body: &[u8],
2412        content_type: Option<&str>,
2413    ) -> Request {
2414        Request {
2415            method: method.to_string(),
2416            path: path.to_string(),
2417            content_type: content_type.map(String::from),
2418            body: body.to_vec(),
2419        }
2420    }
2421
2422    fn body_json(response: &Response) -> serde_json::Value {
2423        serde_json::from_slice(&response.body).unwrap()
2424    }
2425
2426    /// Seat coordinates `(row, col)` for a seated student in an editor draft.
2427    fn editor_seat_coords(editor: &Value, student_key: &str) -> Option<(i64, i64)> {
2428        let entry = editor["students"]
2429            .as_array()?
2430            .iter()
2431            .find(|student| student["student_key"].as_str() == Some(student_key))?;
2432        let seat_id = entry["seat_id"].as_str()?;
2433        let seat = editor["seats"]
2434            .as_array()?
2435            .iter()
2436            .find(|seat| seat["seat_id"].as_str() == Some(seat_id))?;
2437        Some((seat["row"].as_i64()?, seat["col"].as_i64()?))
2438    }
2439
2440    /// Four enabled seats in a single row (deterministic adjacency for tests).
2441    fn line_of_four_layout() -> Value {
2442        json!({
2443            "layout_id": "line-4",
2444            "name": "Line of four",
2445            "seats": [
2446                {"seat_id": "P1", "row": 1, "col": 1, "enabled": true},
2447                {"seat_id": "P2", "row": 1, "col": 2, "enabled": true},
2448                {"seat_id": "P3", "row": 1, "col": 3, "enabled": true},
2449                {"seat_id": "P4", "row": 1, "col": 4, "enabled": true}
2450            ],
2451            "adjacency": {
2452                "include_horizontal": true,
2453                "include_vertical": false,
2454                "include_diagonal": false
2455            }
2456        })
2457    }
2458
2459    /// Build a minimal multipart body with a single `file` field.
2460    fn multipart_body(file_bytes: &[u8], filename: &str, boundary: &str) -> Vec<u8> {
2461        let mut body = Vec::new();
2462        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
2463        body.extend_from_slice(
2464            format!("Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n")
2465                .as_bytes(),
2466        );
2467        body.extend_from_slice(b"Content-Type: text/csv\r\n\r\n");
2468        body.extend_from_slice(file_bytes);
2469        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
2470        body
2471    }
2472
2473    #[test]
2474    fn health_route_returns_expected_shape() {
2475        let root = test_web_root();
2476        let response = route_one(&request("GET", "/api/v1/health", b""), &root);
2477        assert_eq!(response.status, 200);
2478        assert_eq!(
2479            response.content_type,
2480            Some("application/json; charset=utf-8")
2481        );
2482        let value = body_json(&response);
2483        assert_eq!(value["status"], "ok");
2484        assert_eq!(value["service"], "seattrellis");
2485        assert_eq!(value["api_version"], "1");
2486    }
2487
2488    #[test]
2489    fn catalogs_route_returns_bilingual_catalog() {
2490        let root = test_web_root();
2491        let response = route_one(&request("GET", "/api/v1/catalogs", b""), &root);
2492        assert_eq!(response.status, 200);
2493        let value = body_json(&response);
2494        let rooms = value["roomTemplates"].as_array().unwrap();
2495        assert_eq!(rooms.len(), 3);
2496        assert_eq!(rooms[0]["id"], "standard-30");
2497        assert_eq!(rooms[0]["name"]["zh-CN"].as_str().unwrap(), "30 座教室");
2498        assert_eq!(rooms[0]["rows"], 5);
2499        assert_eq!(rooms[0]["columns"], 6);
2500        let goals = value["teacherGoals"].as_array().unwrap();
2501        let goal_ids: Vec<&str> = goals
2502            .iter()
2503            .map(|goal| goal["id"].as_str().unwrap())
2504            .collect();
2505        assert_eq!(
2506            goal_ids,
2507            vec![
2508                "daily-rotation",
2509                "quick-shuffle",
2510                "fair-shuffle",
2511                "peer-support"
2512            ]
2513        );
2514        let formats = value["exportFormats"].as_array().unwrap();
2515        let format_ids: Vec<&str> = formats
2516            .iter()
2517            .map(|format| format["id"].as_str().unwrap())
2518            .collect();
2519        assert_eq!(
2520            format_ids,
2521            vec!["svg", "png", "pdf", "print-html", "xlsx", "docx", "pptx"]
2522        );
2523    }
2524
2525    #[test]
2526    fn rules_templates_route_returns_sentence_templates() {
2527        let root = test_web_root();
2528        let response = route_one(&request("GET", "/api/v1/rules/templates", b""), &root);
2529        assert_eq!(response.status, 200);
2530        let value = body_json(&response);
2531        let templates = value["templates"].as_array().unwrap();
2532        let ids: Vec<&str> = templates
2533            .iter()
2534            .map(|template| template["id"].as_str().unwrap())
2535            .collect();
2536        assert_eq!(
2537            ids,
2538            vec![
2539                "student_distance",
2540                "fixed_seat",
2541                "must_be_adjacent",
2542                "cannot_be_adjacent",
2543                "student_group",
2544                "vision_front",
2545                "score_balance"
2546            ]
2547        );
2548        let distance = &templates[0];
2549        assert_eq!(distance["category"], "hard");
2550        assert_eq!(distance["rule_id"], "min_distance");
2551        assert!(distance["sentence"]["zh"]
2552            .as_str()
2553            .unwrap()
2554            .contains("{student_a}"));
2555        assert!(distance["sentence"]["en"]
2556            .as_str()
2557            .unwrap()
2558            .contains("{student_a}"));
2559        let slots = distance["slots"].as_array().unwrap();
2560        assert_eq!(slots.len(), 3);
2561        assert_eq!(slots[0]["kind"], "student");
2562        assert_eq!(slots[0]["param_path"], "students/0");
2563        assert_eq!(slots[2]["kind"], "number");
2564        assert_eq!(slots[2]["min"], 0.1);
2565    }
2566
2567    #[test]
2568    fn rules_compile_route_binds_slots_and_reports_errors() {
2569        let root = test_web_root();
2570        let body = br#"{
2571            "template_id": "student_distance",
2572            "slots": { "student_a": "S01", "student_b": "S02", "distance": 2.5 }
2573        }"#;
2574        let response = route_one(&request("POST", "/api/v1/rules/compile", body), &root);
2575        assert_eq!(response.status, 200);
2576        let value = body_json(&response);
2577        assert_eq!(value["category"], "hard");
2578        assert_eq!(value["rule_id"], "min_distance");
2579        assert_eq!(
2580            value["entry"],
2581            json!({
2582                "students": ["S01", "S02"],
2583                "distance": 2.5,
2584                "metric": "graph",
2585            })
2586        );
2587
2588        // Missing required slot -> structured 422.
2589        let missing = route_one(
2590            &request(
2591                "POST",
2592                "/api/v1/rules/compile",
2593                br#"{"template_id": "student_distance", "slots": {"student_a": "S01"}}"#,
2594            ),
2595            &root,
2596        );
2597        assert_eq!(missing.status, 422);
2598        let value = body_json(&missing);
2599        assert_eq!(value["code"], "missing_slot");
2600        assert_eq!(value["slot"], "student_b");
2601
2602        // Unknown template -> structured 422.
2603        let unknown = route_one(
2604            &request(
2605                "POST",
2606                "/api/v1/rules/compile",
2607                br#"{"template_id": "nope", "slots": {}}"#,
2608            ),
2609            &root,
2610        );
2611        assert_eq!(unknown.status, 422);
2612        assert_eq!(body_json(&unknown)["code"], "unknown_template");
2613    }
2614
2615    #[test]
2616    fn rules_validate_route_reports_registry_diagnostics() {
2617        let root = test_web_root();
2618        let body = br#"{
2619            "source": "{\"hard\": {\"fixed_seats\": [{\"student\": \"S01\", \"seat_id\": \"R9C9\"}]}}",
2620            "students": ["S01", "S02"],
2621            "seats": ["R1C1", "R1C2"]
2622        }"#;
2623        let response = route_one(&request("POST", "/api/v1/rules/validate", body), &root);
2624        assert_eq!(response.status, 200);
2625        let value = body_json(&response);
2626        let diagnostics = value["diagnostics"].as_array().unwrap();
2627        assert!(
2628            diagnostics
2629                .iter()
2630                .any(|d| d["code"] == "unknown_seat" && d["path"] == "hard.fixed_seats[0].seat_id"),
2631            "expected unknown_seat diagnostic, got {diagnostics:?}"
2632        );
2633
2634        // Clean input -> empty diagnostics.
2635        let clean_body = br#"{
2636            "source": "{\"hard\": {\"fixed_seats\": [{\"student\": \"S01\", \"seat_id\": \"R1C1\"}]}}",
2637            "students": ["S01", "S02"],
2638            "seats": ["R1C1", "R1C2"]
2639        }"#;
2640        let clean = route_one(
2641            &request("POST", "/api/v1/rules/validate", clean_body),
2642            &root,
2643        );
2644        assert_eq!(clean.status, 200);
2645        assert!(body_json(&clean)["diagnostics"]
2646            .as_array()
2647            .unwrap()
2648            .is_empty());
2649
2650        // Reject malformed wrapper bodies.
2651        let bad = route_one(&request("POST", "/api/v1/rules/validate", b"{}"), &root);
2652        assert_eq!(bad.status, 400);
2653    }
2654
2655    #[test]
2656    fn index_route_serves_workbench() {
2657        let root = test_web_root();
2658        let response = route_one(&request("GET", "/", b""), &root);
2659        assert_eq!(response.status, 200);
2660        assert_eq!(response.content_type, Some("text/html; charset=utf-8"));
2661        assert_eq!(response.body, b"<html>test workbench</html>");
2662    }
2663
2664    #[test]
2665    fn embedded_workbench_is_used_when_filesystem_root_is_missing() {
2666        let root = std::env::temp_dir().join(format!(
2667            "seattrellis_missing_web_root_{}",
2668            std::process::id()
2669        ));
2670        let _ = fs::remove_dir_all(&root);
2671
2672        let index = route_one(&request("GET", "/", b""), &root);
2673        assert_eq!(index.status, 200);
2674        assert_eq!(index.content_type, Some("text/html; charset=utf-8"));
2675        assert_eq!(
2676            index.body.as_slice(),
2677            crate::embedded_web::get("index.html").unwrap()
2678        );
2679
2680        let asset_path = crate::embedded_web::EMBEDDED_WEB_ASSETS
2681            .iter()
2682            .find_map(|(path, _)| path.strip_prefix("assets/").map(|_| *path))
2683            .expect("embedded workbench should contain an asset");
2684        let asset = route_one(&request("GET", &format!("/{asset_path}"), b""), &root);
2685        assert_eq!(asset.status, 200);
2686        assert_eq!(
2687            asset.body.as_slice(),
2688            crate::embedded_web::get(asset_path).unwrap()
2689        );
2690    }
2691
2692    #[test]
2693    fn static_asset_route_serves_file() {
2694        let root = test_web_root();
2695        let response = route_one(&request("GET", "/assets/app.js", b""), &root);
2696        assert_eq!(response.status, 200);
2697        assert_eq!(
2698            response.content_type,
2699            Some("text/javascript; charset=utf-8")
2700        );
2701        assert_eq!(response.body, b"console.log('hi');");
2702    }
2703
2704    #[test]
2705    fn dotdot_traversal_is_rejected() {
2706        let root = test_web_root();
2707        let response = route_one(&request("GET", "/../etc/passwd", b""), &root);
2708        assert_eq!(response.status, 404);
2709    }
2710
2711    #[test]
2712    fn percent_encoded_traversal_is_rejected() {
2713        let root = test_web_root();
2714        let response = route_one(&request("GET", "/%2e%2e/secret", b""), &root);
2715        assert_eq!(response.status, 404);
2716    }
2717
2718    #[test]
2719    fn unknown_static_file_is_404() {
2720        let root = test_web_root();
2721        let response = route_one(&request("GET", "/does-not-exist.js", b""), &root);
2722        assert_eq!(response.status, 404);
2723    }
2724
2725    #[test]
2726    fn generate_feasible_returns_class_response_with_editor() {
2727        let root = test_web_root();
2728        let problem = json!({
2729            "api_version": 2,
2730            "student_count": 5,
2731            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0],[4.0,1.0],[5.0,1.0],[6.0,1.0],[7.0,1.0],[8.0,1.0],[9.0,1.0]]
2732        });
2733        let body = serde_json::to_vec(&problem).unwrap();
2734        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
2735        assert_eq!(
2736            response.status,
2737            200,
2738            "body: {}",
2739            String::from_utf8_lossy(&response.body)
2740        );
2741        let value = body_json(&response);
2742        assert_eq!(value["class_name"], "Classroom");
2743        assert_eq!(value["warnings"], json!([]));
2744        assert_eq!(value["goal"]["goal_id"], "daily-rotation");
2745        let recommended = value["recommended_candidate_id"].as_str().unwrap();
2746        let candidates = value["candidates"].as_array().unwrap();
2747        assert_eq!(candidates.len(), 1);
2748        assert_eq!(candidates[0]["candidate_id"], recommended);
2749        assert_eq!(candidates[0]["recommended"], true);
2750        let editor = &value["editor"];
2751        assert_eq!(editor["kind"], "seattrellis_editor_state");
2752        assert_eq!(editor["protocol_version"], "1.0");
2753        assert_eq!(editor["draft_id"], recommended);
2754        assert_eq!(editor["students"].as_array().map(Vec::len), Some(5));
2755        assert_eq!(editor["seats"].as_array().map(Vec::len), Some(9));
2756        for student in editor["students"].as_array().unwrap() {
2757            assert!(student["seat_id"].is_string());
2758        }
2759    }
2760
2761    #[test]
2762    fn draft_audit_reports_score_and_hard_summary_for_a_generated_draft() {
2763        let root = test_web_root();
2764        let editor_store = editing::new_draft_store();
2765        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
2766        let problem = json!({
2767            "api_version": 2,
2768            "student_count": 5,
2769            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0],[4.0,1.0],[5.0,1.0],[6.0,1.0],[7.0,1.0],[8.0,1.0],[9.0,1.0]]
2770        });
2771        let body = serde_json::to_vec(&problem).unwrap();
2772        let generated = route_with_store(
2773            &request("POST", "/api/v1/classes/generate", &body),
2774            &root,
2775            &editor_store,
2776            &solve_requests,
2777        );
2778        let value = body_json(&generated);
2779        let draft_id = value["recommended_candidate_id"]
2780            .as_str()
2781            .unwrap()
2782            .to_string();
2783
2784        let audit = route_with_store(
2785            &request(
2786                "GET",
2787                &format!("/api/v1/editing/drafts/{draft_id}/audit"),
2788                b"",
2789            ),
2790            &root,
2791            &editor_store,
2792            &solve_requests,
2793        );
2794        assert_eq!(
2795            audit.status,
2796            200,
2797            "body: {}",
2798            String::from_utf8_lossy(&audit.body)
2799        );
2800        let report = body_json(&audit);
2801        assert_eq!(report["api_version"], "1");
2802        assert_eq!(report["draft_id"], draft_id);
2803        assert_eq!(report["feasible"], true);
2804        assert!(report["score"]["total"].is_f64());
2805        let breakdown = &report["score"]["breakdown"];
2806        for key in [
2807            "fair_rotation_score",
2808            "avoid_recent_neighbors_score",
2809            "score_balance_score",
2810            "height_preference_score",
2811            "vision_preference_score",
2812            "diversity_score",
2813            "stability_score",
2814        ] {
2815            assert!(breakdown[key].is_object(), "missing dimension {key}");
2816        }
2817        assert!(report["audit"]["hard_constraint_summary"].is_object());
2818        assert!(report["audit"]["suggested_actions"].is_array());
2819
2820        // Unknown draft -> 404.
2821        let missing = route_with_store(
2822            &request("GET", "/api/v1/editing/drafts/nope/audit", b""),
2823            &root,
2824            &editor_store,
2825            &solve_requests,
2826        );
2827        assert_eq!(missing.status, 404);
2828    }
2829
2830    #[test]
2831    fn generate_returns_multiple_candidates_when_requested() {
2832        let root = test_web_root();
2833        let editor_store = editing::new_draft_store();
2834        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
2835        let problem = json!({
2836            "api_version": 2,
2837            "student_count": 6,
2838            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0],[4.0,1.0],[5.0,1.0],[6.0,1.0],[7.0,1.0],[8.0,1.0],[9.0,1.0],[10.0,1.0]],
2839            "seed": 42,
2840            "options": { "candidate_count": 5 }
2841        });
2842        let body = serde_json::to_vec(&problem).unwrap();
2843        let response = route_with_store(
2844            &request("POST", "/api/v1/classes/generate", &body),
2845            &root,
2846            &editor_store,
2847            &solve_requests,
2848        );
2849        assert_eq!(
2850            response.status,
2851            200,
2852            "body: {}",
2853            String::from_utf8_lossy(&response.body)
2854        );
2855        let value = body_json(&response);
2856        let candidates = value["candidates"].as_array().unwrap();
2857        assert_eq!(candidates.len(), 5, "expected 5 candidates: {value}");
2858        let ids: Vec<&str> = candidates
2859            .iter()
2860            .map(|candidate| candidate["candidate_id"].as_str().unwrap())
2861            .collect();
2862        assert_eq!(ids.len(), 5);
2863        let recommended = value["recommended_candidate_id"].as_str().unwrap();
2864        assert!(ids.contains(&recommended));
2865        assert_eq!(
2866            candidates
2867                .iter()
2868                .filter(|candidate| candidate["recommended"] == true)
2869                .count(),
2870            1
2871        );
2872        // Every candidate is an editable, auditable draft.
2873        for candidate_id in ids {
2874            let audit = route_with_store(
2875                &request(
2876                    "GET",
2877                    &format!("/api/v1/editing/drafts/{candidate_id}/audit"),
2878                    b"",
2879                ),
2880                &root,
2881                &editor_store,
2882                &solve_requests,
2883            );
2884            assert_eq!(
2885                audit.status,
2886                200,
2887                "candidate {candidate_id} is not auditable: {}",
2888                String::from_utf8_lossy(&audit.body)
2889            );
2890        }
2891    }
2892
2893    #[test]
2894    fn generate_with_named_students_uses_keys() {
2895        let root = test_web_root();
2896        let problem = json!({
2897            "api_version": 2,
2898            "student_count": 2,
2899            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0]],
2900            "students": [
2901                {"key": "S1", "display_name": "Alice", "score": 93.0},
2902                {"key": "S2", "display_name": "Bob", "score": 81.0}
2903            ]
2904        });
2905        let body = serde_json::to_vec(&problem).unwrap();
2906        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
2907        assert_eq!(response.status, 200);
2908        let value = body_json(&response);
2909        let editor = &value["editor"];
2910        let student_keys: Vec<&str> = editor["students"]
2911            .as_array()
2912            .unwrap()
2913            .iter()
2914            .map(|student| student["student_key"].as_str().unwrap())
2915            .collect();
2916        assert_eq!(student_keys, vec!["S1", "S2"]);
2917    }
2918
2919    #[test]
2920    fn solve_alias_route_works() {
2921        let root = test_web_root();
2922        let problem = json!({
2923            "api_version": 2,
2924            "student_count": 2,
2925            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0]]
2926        });
2927        let body = serde_json::to_vec(&problem).unwrap();
2928        let response = route_one(&request("POST", "/api/v1/solve", &body), &root);
2929        assert_eq!(response.status, 200);
2930        let value = body_json(&response);
2931        assert!(value["editor"]["draft_id"].is_string());
2932    }
2933
2934    /// The React workbench's `GenerateClassRequest` shape: a draft carrying
2935    /// students, a room template id and a goal id. It must be expanded onto a
2936    /// room grid + goal rules, solved, and returned as a `GenerateClassResponse`
2937    /// with a created editor draft.
2938    #[test]
2939    fn generate_frontend_class_request_adapts_and_creates_draft() {
2940        let root = test_web_root();
2941        let problem = json!({
2942            "draft": {
2943                "name": "Physics Period 3",
2944                "students": [
2945                    {"student_id": "S1", "name": "Alice", "score": 93, "height_cm": 160},
2946                    {"student_id": "S2", "name": "Bob", "score": 81, "height_cm": 172},
2947                    {"student_id": "S3", "name": "Carol", "score": 75, "height_cm": 150}
2948                ],
2949                "room": {"template_id": "standard-30"},
2950                "goal": {"goal_id": "daily-rotation"}
2951            },
2952            "options": {"candidate_count": 1, "seed": 42}
2953        });
2954        let body = serde_json::to_vec(&problem).unwrap();
2955        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
2956        assert_eq!(
2957            response.status,
2958            200,
2959            "body: {}",
2960            String::from_utf8_lossy(&response.body)
2961        );
2962        let value = body_json(&response);
2963
2964        // GenerateClassResponse shape.
2965        assert_eq!(value["goal"]["goal_id"], "daily-rotation");
2966        assert_eq!(value["warnings"], json!([]));
2967        let draft_id = value["editor"]["draft_id"].as_str().unwrap();
2968        assert_eq!(value["recommended_candidate_id"], draft_id);
2969        let candidates = value["candidates"].as_array().unwrap();
2970        assert_eq!(candidates.len(), 1);
2971        assert_eq!(candidates[0]["candidate_id"], draft_id);
2972        assert_eq!(candidates[0]["recommended"], true);
2973        let total_score = candidates[0]["total_score"]
2974            .as_f64()
2975            .expect("total_score is a number");
2976        assert!(total_score.is_finite());
2977
2978        // The editor draft mirrors the 3 students and the 30-seat template.
2979        let editor = &value["editor"];
2980        assert_eq!(editor["draft_id"], draft_id);
2981        let students = editor["students"].as_array().unwrap();
2982        assert_eq!(students.len(), 3);
2983        let keys: Vec<&str> = students
2984            .iter()
2985            .map(|student| student["student_key"].as_str().unwrap())
2986            .collect();
2987        assert_eq!(keys, vec!["S1", "S2", "S3"]);
2988        for student in students {
2989            assert!(
2990                student["seat_id"].is_string(),
2991                "every student is seated: {student}"
2992            );
2993        }
2994        assert_eq!(editor["seats"].as_array().map(Vec::len), Some(30));
2995        // The template's row-1 leftmost seat id is R1C1 (grid coordinates).
2996        let seats = editor["seats"].as_array().unwrap();
2997        assert_eq!(seats[0]["seat_id"], "R1C1");
2998        assert_eq!(seats[0]["enabled"], true);
2999        // Seat 30 is the last enabled seat: row 5, grid column 7.
3000        assert_eq!(seats[29]["seat_id"], "R5C7");
3001    }
3002
3003    /// The frontend path must echo the requested goal id (not hardcode it).
3004    #[test]
3005    fn generate_frontend_class_request_echoes_requested_goal() {
3006        let root = test_web_root();
3007        let problem = json!({
3008            "draft": {
3009                "name": "Peer Class",
3010                "students": [
3011                    {"student_id": "S1", "name": "Alice", "score": 95},
3012                    {"student_id": "S2", "name": "Bob", "score": 60},
3013                    {"student_id": "S3", "name": "Carol", "score": 40}
3014                ],
3015                "room": {"template_id": "standard-48"},
3016                "goal": {"goal_id": "peer-support"}
3017            }
3018        });
3019        let body = serde_json::to_vec(&problem).unwrap();
3020        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
3021        assert_eq!(
3022            response.status,
3023            200,
3024            "body: {}",
3025            String::from_utf8_lossy(&response.body)
3026        );
3027        let value = body_json(&response);
3028        assert_eq!(value["goal"]["goal_id"], "peer-support");
3029        assert_eq!(
3030            value["editor"]["students"].as_array().map(Vec::len),
3031            Some(3)
3032        );
3033        assert_eq!(value["editor"]["seats"].as_array().map(Vec::len), Some(48));
3034    }
3035
3036    /// An unknown room template id on the frontend path is a 422.
3037
3038    #[test]
3039    fn rotation_generate_creates_multi_period_plan() {
3040        let root = test_web_root();
3041        let problem = json!({
3042            "draft": {
3043                "name": "Physics Rotation",
3044                "students": [
3045                    {"student_id": "S1", "name": "Alice", "score": 93},
3046                    {"student_id": "S2", "name": "Bob", "score": 81},
3047                    {"student_id": "S3", "name": "Carol", "score": 75}
3048                ],
3049                "room": {"template_id": "standard-30"},
3050                "goal": {"goal_id": "daily-rotation"}
3051            },
3052            "period_count": 3,
3053            "period_labels": ["Week 1", "Week 2", "Week 3"],
3054            "options": {"seed": 42}
3055        });
3056        let body = serde_json::to_vec(&problem).unwrap();
3057        let response = route_one(&request("POST", "/api/v1/classes/rotation", &body), &root);
3058        assert_eq!(
3059            response.status,
3060            200,
3061            "body: {}",
3062            String::from_utf8_lossy(&response.body)
3063        );
3064        let value = body_json(&response);
3065
3066        // RotationPlan shape: three labelled periods, each with assignments.
3067        let plan = &value["rotation_plan"];
3068        assert_eq!(plan["kind"], "rotation_plan");
3069        assert_eq!(plan["name"], "Physics Rotation");
3070        let periods = plan["periods"].as_array().unwrap();
3071        assert_eq!(periods.len(), 3);
3072        assert_eq!(periods[0]["label"], "Week 1");
3073        assert_eq!(periods[2]["label"], "Week 3");
3074        for period in periods {
3075            let assignments = period["snapshot"]["assignments"].as_array().unwrap();
3076            assert_eq!(assignments.len(), 3, "every period seats all students");
3077            assert!(period["snapshot"]["solver_status"].is_string());
3078        }
3079        assert_eq!(plan["base_history_count"], 0);
3080        assert_eq!(plan["metadata"]["period_count"], 3);
3081        assert_eq!(plan["metadata"]["backend"], "native");
3082        // Fairness + pair summaries are present and count the periods.
3083        assert_eq!(plan["fairness_summary"]["history_count"], 3);
3084        assert_eq!(plan["pair_repeat_summary"]["history_count"], 3);
3085
3086        // The response carries a first-period editor draft the workbench can
3087        // open immediately.
3088        let editor = &value["editor"];
3089        assert!(editor["draft_id"].as_str().is_some());
3090        assert_eq!(editor["students"].as_array().unwrap().len(), 3);
3091        assert!(
3092            value["class_name"]
3093                .as_str()
3094                .is_some_and(|name| !name.is_empty()),
3095            "class_name: {}",
3096            value["class_name"]
3097        );
3098        assert_eq!(value["warnings"], json!([]));
3099    }
3100
3101    #[test]
3102    fn rotation_generate_rejects_unknown_room() {
3103        let root = test_web_root();
3104        let problem = json!({
3105            "draft": {
3106                "students": [{"student_id": "S1", "name": "Alice"}],
3107                "room": {"template_id": "standard-99"},
3108                "goal": {"goal_id": "daily-rotation"}
3109            },
3110            "period_count": 2
3111        });
3112        let body = serde_json::to_vec(&problem).unwrap();
3113        let response = route_one(&request("POST", "/api/v1/classes/rotation", &body), &root);
3114        assert_eq!(
3115            response.status,
3116            422,
3117            "body: {}",
3118            String::from_utf8_lossy(&response.body)
3119        );
3120    }
3121
3122    #[test]
3123    fn rotation_generate_uses_base_history_snapshots() {
3124        let root = test_web_root();
3125        let problem = json!({
3126            "draft": {
3127                "name": "Rotation With History",
3128                "students": [
3129                    {"student_id": "S1", "name": "Alice"},
3130                    {"student_id": "S2", "name": "Bob"}
3131                ],
3132                "room": {"template_id": "standard-30"},
3133                "goal": {"goal_id": "daily-rotation"},
3134                "history_snapshots": [{
3135                    "assignments": [
3136                        {"student_key": "S1", "seat_id": "R1C1"},
3137                        {"student_key": "S2", "seat_id": "R1C2"}
3138                    ]
3139                }]
3140            },
3141            "period_count": 2,
3142            "options": {"seed": 7}
3143        });
3144        let body = serde_json::to_vec(&problem).unwrap();
3145        let response = route_one(&request("POST", "/api/v1/classes/rotation", &body), &root);
3146        assert_eq!(
3147            response.status,
3148            200,
3149            "body: {}",
3150            String::from_utf8_lossy(&response.body)
3151        );
3152        let plan = &body_json(&response)["rotation_plan"];
3153        assert_eq!(plan["base_history_count"], 1, "one base snapshot");
3154        assert_eq!(
3155            plan["fairness_summary"]["history_count"], 3,
3156            "base + 2 generated periods"
3157        );
3158    }
3159
3160    #[test]
3161    fn artifact_compare_and_restore_routes_work() {
3162        let root = test_web_root();
3163        let dir = std::env::temp_dir().join(format!(
3164            "seattrellis_artifact_route_test_{}",
3165            std::process::id()
3166        ));
3167        let _ = fs::remove_dir_all(&dir);
3168        fs::create_dir_all(&dir).unwrap();
3169        fs::write(
3170            dir.join("project.json"),
3171            r#"{
3172            "kind": "seattrellis_project",
3173            "name": "Demo",
3174            "students": "students.csv",
3175            "layout": "classroom.json",
3176            "rules": "rules.json",
3177            "outputs_dir": "outputs"
3178        }"#,
3179        )
3180        .unwrap();
3181        fs::create_dir_all(dir.join("outputs")).unwrap();
3182        fs::write(dir.join("outputs/a.json"), r#"{
3183            "kind": "snapshot",
3184            "created_at": "2026-08-09T00:00:00Z",
3185            "students": [{"student_id": "S1", "name": "Alice"}],
3186            "layout": {"layout_id": "l", "seats": [
3187                {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3188                {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3189            ]},
3190            "rules": {"seed": 42},
3191            "assignments": [{"student_key": "S1", "student_name": "Alice", "seat_id": "R1C1"}],
3192            "solver_status": "FEASIBLE"
3193        }"#).unwrap();
3194        fs::write(dir.join("outputs/b.json"), r#"{
3195            "kind": "snapshot",
3196            "created_at": "2026-08-09T01:00:00Z",
3197            "students": [{"student_id": "S1", "name": "Alice"}],
3198            "layout": {"layout_id": "l", "seats": [
3199                {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3200                {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3201            ]},
3202            "rules": {"seed": 42},
3203            "assignments": [{"student_key": "S1", "student_name": "Alice", "seat_id": "R1C2"}],
3204            "solver_status": "FEASIBLE"
3205        }"#).unwrap();
3206
3207        // compare
3208        let body = serde_json::to_vec(&json!({
3209            "project_path": dir.join("project.json"),
3210            "artifact_path": dir.join("outputs/a.json"),
3211            "compare_to_path": dir.join("outputs/b.json"),
3212        }))
3213        .unwrap();
3214        let response = route_one(
3215            &request("POST", "/api/v1/projects/artifacts/compare", &body),
3216            &root,
3217        );
3218        assert_eq!(
3219            response.status,
3220            200,
3221            "body: {}",
3222            String::from_utf8_lossy(&response.body)
3223        );
3224        let value = body_json(&response);
3225        assert_eq!(value["diff"]["assignment_changes"], 1);
3226        assert_eq!(value["diff"]["assignment_details"][0]["change"], "moved");
3227
3228        // restore
3229        let body = serde_json::to_vec(&json!({
3230            "project_path": dir.join("project.json"),
3231            "artifact_path": dir.join("outputs/a.json"),
3232        }))
3233        .unwrap();
3234        let response = route_one(
3235            &request("POST", "/api/v1/projects/artifacts/restore", &body),
3236            &root,
3237        );
3238        assert_eq!(
3239            response.status,
3240            200,
3241            "body: {}",
3242            String::from_utf8_lossy(&response.body)
3243        );
3244        let value = body_json(&response);
3245        let restored = value["restored_artifact"].as_str().unwrap();
3246        assert!(restored.ends_with("restored-a.snapshot.json"), "{restored}");
3247        assert!(Path::new(restored).is_file());
3248
3249        let _ = fs::remove_dir_all(&dir);
3250    }
3251
3252    // ---- M2 parity contract: artifact compare + restore (ledger §2.7/§3.2) ----
3253    //
3254    // Server-level contract tests for `POST /api/v1/projects/artifacts/compare`
3255    // and `POST /api/v1/projects/artifacts/restore` (io projects.rs:1786/:1882).
3256    // Every case asserts a 2xx/4xx status (never 5xx), a JSON envelope, and the
3257    // fields the React client (`clients/web/src/api/client.ts`) actually reads.
3258
3259    /// A scratch project workspace for artifact compare/restore contract tests.
3260    /// Each instance owns a unique temp dir that is removed on drop.
3261    struct ArtifactProject {
3262        dir: PathBuf,
3263        project_file: PathBuf,
3264    }
3265
3266    impl ArtifactProject {
3267        fn new(tag: &str) -> ArtifactProject {
3268            let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
3269            let dir = std::env::temp_dir().join(format!(
3270                "seattrellis_artifact_contract_{}_{}_{}",
3271                std::process::id(),
3272                seq,
3273                tag
3274            ));
3275            let _ = fs::remove_dir_all(&dir);
3276            fs::create_dir_all(&dir).unwrap();
3277            fs::write(
3278                dir.join("project.json"),
3279                r#"{
3280                "kind": "seattrellis_project",
3281                "name": "Demo",
3282                "students": "students.csv",
3283                "layout": "classroom.json",
3284                "rules": "rules.json",
3285                "history_dir": "history",
3286                "outputs_dir": "outputs"
3287            }"#,
3288            )
3289            .unwrap();
3290            ArtifactProject {
3291                dir: dir.clone(),
3292                project_file: dir.join("project.json"),
3293            }
3294        }
3295
3296        fn write(&self, relative: &str, content: &str) -> PathBuf {
3297            let path = self.dir.join(relative);
3298            if let Some(parent) = path.parent() {
3299                fs::create_dir_all(parent).unwrap();
3300            }
3301            fs::write(&path, content).unwrap();
3302            path
3303        }
3304
3305        fn compare(&self, left: &Path, right: &Path, root: &Path) -> Response {
3306            let body = serde_json::to_vec(&json!({
3307                "project_path": self.project_file.to_string_lossy().into_owned(),
3308                "artifact_path": left.to_string_lossy().into_owned(),
3309                "compare_to_path": right.to_string_lossy().into_owned(),
3310            }))
3311            .unwrap();
3312            route_one(
3313                &request("POST", "/api/v1/projects/artifacts/compare", &body),
3314                root,
3315            )
3316        }
3317
3318        fn restore(&self, artifact: &Path, root: &Path) -> Response {
3319            let body = serde_json::to_vec(&json!({
3320                "project_path": self.project_file.to_string_lossy().into_owned(),
3321                "artifact_path": artifact.to_string_lossy().into_owned(),
3322            }))
3323            .unwrap();
3324            route_one(
3325                &request("POST", "/api/v1/projects/artifacts/restore", &body),
3326                root,
3327            )
3328        }
3329    }
3330
3331    impl Drop for ArtifactProject {
3332        fn drop(&mut self) {
3333            let _ = fs::remove_dir_all(&self.dir);
3334        }
3335    }
3336
3337    /// Assert a structured JSON error: exactly `expected_status` (a 4xx), a
3338    /// JSON body carrying the `error` envelope field the OpenAPI
3339    /// ErrorEnvelope contract requires, and a message containing the fragment.
3340    fn assert_error_envelope(
3341        response: &Response,
3342        expected_status: u16,
3343        message_fragment: &str,
3344    ) -> serde_json::Value {
3345        assert_eq!(
3346            response.status,
3347            expected_status,
3348            "expected {expected_status}, got {} body: {}",
3349            response.status,
3350            String::from_utf8_lossy(&response.body)
3351        );
3352        assert!(
3353            (400..500).contains(&response.status),
3354            "contract: clean 4xx, got {}",
3355            response.status
3356        );
3357        assert_eq!(
3358            response.content_type,
3359            Some("application/json; charset=utf-8"),
3360            "error bodies must be JSON"
3361        );
3362        let value = body_json(response);
3363        let error = value["error"]
3364            .as_str()
3365            .expect("error envelope must carry an 'error' string field");
3366        assert!(!error.is_empty(), "error message must not be empty");
3367        assert!(
3368            error.contains(message_fragment),
3369            "error {error:?} does not contain {message_fragment:?}"
3370        );
3371        value
3372    }
3373
3374    /// Two snapshots with real student names. LEFT: Alice@R1C1, Bob@R1C2,
3375    /// FEASIBLE, rules seed 42, two seats. RIGHT: Alice moved to R1C2, Bob
3376    /// unseated, Carol seated at R1C1, OPTIMAL, rules seed 7, three seats.
3377    const SNAPSHOT_LEFT: &str = r#"{
3378        "kind": "snapshot",
3379        "schema_version": "1.0",
3380        "created_at": "2026-08-09T00:00:00Z",
3381        "students": [
3382            {"student_id": "S1", "name": "Alice"},
3383            {"student_id": "S2", "name": "Bob"}
3384        ],
3385        "layout": {"layout_id": "l", "seats": [
3386            {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3387            {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3388        ]},
3389        "rules": {"seed": 42},
3390        "assignments": [
3391            {"student_key": "S1", "student_name": "Alice", "seat_id": "R1C1"},
3392            {"student_key": "S2", "student_name": "Bob", "seat_id": "R1C2"}
3393        ],
3394        "solver_status": "FEASIBLE"
3395    }"#;
3396
3397    const SNAPSHOT_RIGHT: &str = r#"{
3398        "kind": "snapshot",
3399        "schema_version": "1.0",
3400        "created_at": "2026-08-09T01:00:00Z",
3401        "students": [
3402            {"student_id": "S1", "name": "Alice"},
3403            {"student_id": "S2", "name": "Bob"},
3404            {"student_id": "S3", "name": "Carol"}
3405        ],
3406        "layout": {"layout_id": "l", "seats": [
3407            {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3408            {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true},
3409            {"seat_id": "R1C3", "row": 1, "col": 3, "x": 3.0, "y": 1.0, "zone": "front", "enabled": true}
3410        ]},
3411        "rules": {"seed": 7},
3412        "assignments": [
3413            {"student_key": "S1", "student_name": "Alice", "seat_id": "R1C2"},
3414            {"student_key": "S3", "student_name": "Carol", "seat_id": "R1C1"}
3415        ],
3416        "solver_status": "OPTIMAL"
3417    }"#;
3418
3419    /// Candidate-set documents whose recommended inner snapshot moves S1
3420    /// from R1C1 to R1C2.
3421    const CANDIDATE_SET_LEFT: &str = r#"{
3422        "kind": "candidate_set",
3423        "schema_version": "0.2.2",
3424        "created_at": "2026-08-09T00:00:00Z",
3425        "recommended_candidate_id": "c1",
3426        "candidates": [
3427            {
3428                "candidate_id": "c1",
3429                "snapshot": {
3430                    "schema_version": "1.0",
3431                    "students": [{"student_id": "S1", "name": "Alice"}],
3432                    "layout": {"layout_id": "l", "seats": [
3433                        {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3434                        {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3435                    ]},
3436                    "rules": {"seed": 1},
3437                    "assignments": [{"student_key": "S1", "student_name": "Alice", "seat_id": "R1C1"}],
3438                    "solver_status": "FEASIBLE"
3439                }
3440            }
3441        ]
3442    }"#;
3443
3444    const CANDIDATE_SET_RIGHT: &str = r#"{
3445        "kind": "candidate_set",
3446        "schema_version": "0.2.2",
3447        "created_at": "2026-08-09T02:00:00Z",
3448        "recommended_candidate_id": "c1",
3449        "candidates": [
3450            {
3451                "candidate_id": "c1",
3452                "snapshot": {
3453                    "schema_version": "1.0",
3454                    "students": [{"student_id": "S1", "name": "Alice"}],
3455                    "layout": {"layout_id": "l", "seats": [
3456                        {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3457                        {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3458                    ]},
3459                    "rules": {"seed": 2},
3460                    "assignments": [{"student_key": "S1", "student_name": "Alice", "seat_id": "R1C2"}],
3461                    "solver_status": "FEASIBLE"
3462                }
3463            }
3464        ]
3465    }"#;
3466
3467    /// Rotation plans whose first period snapshot moves S1 from R1C1 to R1C2.
3468    const ROTATION_PLAN_LEFT: &str = r#"{
3469        "kind": "rotation_plan",
3470        "schema_version": "1.0",
3471        "created_at": "2026-08-09T00:00:00Z",
3472        "name": "Weekly",
3473        "periods": [
3474            {"period": 1, "label": "P1", "snapshot": {
3475                "schema_version": "1.0",
3476                "students": [{"student_id": "S1", "name": "Alice"}],
3477                "layout": {"layout_id": "l", "seats": [
3478                    {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3479                    {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3480                ]},
3481                "rules": {"seed": 1},
3482                "assignments": [{"student_key": "S1", "student_name": "Alice", "seat_id": "R1C1"}],
3483                "solver_status": "FEASIBLE"
3484            }}
3485        ]
3486    }"#;
3487
3488    const ROTATION_PLAN_RIGHT: &str = r#"{
3489        "kind": "rotation_plan",
3490        "schema_version": "1.0",
3491        "created_at": "2026-08-09T03:00:00Z",
3492        "name": "Weekly",
3493        "periods": [
3494            {"period": 1, "label": "P1", "snapshot": {
3495                "schema_version": "1.0",
3496                "students": [{"student_id": "S1", "name": "Alice"}],
3497                "layout": {"layout_id": "l", "seats": [
3498                    {"seat_id": "R1C1", "row": 1, "col": 1, "x": 1.0, "y": 1.0, "zone": "front", "enabled": true},
3499                    {"seat_id": "R1C2", "row": 1, "col": 2, "x": 2.0, "y": 1.0, "zone": "front", "enabled": true}
3500                ]},
3501                "rules": {"seed": 2},
3502                "assignments": [{"student_key": "S1", "student_name": "Alice", "seat_id": "R1C2"}],
3503                "solver_status": "FEASIBLE"
3504            }}
3505        ]
3506    }"#;
3507
3508    #[test]
3509    fn artifact_compare_full_diff_contract_and_privacy() {
3510        let root = test_web_root();
3511        let project = ArtifactProject::new("compare-full");
3512        let left = project.write("history/snap-l.json", SNAPSHOT_LEFT);
3513        let right = project.write("history/snap-r.json", SNAPSHOT_RIGHT);
3514
3515        let response = project.compare(&left, &right, &root);
3516        assert_eq!(
3517            response.status,
3518            200,
3519            "body: {}",
3520            String::from_utf8_lossy(&response.body)
3521        );
3522        assert_eq!(
3523            response.content_type,
3524            Some("application/json; charset=utf-8")
3525        );
3526        let value = body_json(&response);
3527
3528        // Client envelope (`ProjectArtifactCompareResponse` in types.ts).
3529        assert_eq!(value["api_version"], "1");
3530
3531        // Client summaries (`ProjectArtifactSummary`).
3532        assert_eq!(value["left"]["name"], "snap-l.json");
3533        assert_eq!(value["left"]["kind"], "snapshot");
3534        assert_eq!(value["left"]["created_at"], "2026-08-09T00:00:00Z");
3535        assert_eq!(value["left"]["student_count"], 2);
3536        assert_eq!(value["left"]["assignment_count"], 2);
3537        assert_eq!(value["left"]["enabled_seat_count"], 2);
3538        assert_eq!(value["left"]["solver_status"], "FEASIBLE");
3539        assert!(value["left"]["path"]
3540            .as_str()
3541            .unwrap()
3542            .ends_with("snap-l.json"));
3543        assert_eq!(value["right"]["name"], "snap-r.json");
3544        assert_eq!(value["right"]["kind"], "snapshot");
3545        assert_eq!(value["right"]["created_at"], "2026-08-09T01:00:00Z");
3546        assert_eq!(value["right"]["student_count"], 3);
3547        assert_eq!(value["right"]["assignment_count"], 2);
3548        assert_eq!(value["right"]["enabled_seat_count"], 3);
3549        assert_eq!(value["right"]["solver_status"], "OPTIMAL");
3550
3551        // Client diff (`ProjectArtifactDiff`): S1 moved, S2 unseated, S3
3552        // seated; layout/rules/solver_status all changed.
3553        let diff = &value["diff"];
3554        assert_eq!(diff["assignment_changes"], 3);
3555        assert_eq!(diff["roster_added"], 1);
3556        assert_eq!(diff["roster_removed"], 1);
3557        assert_eq!(diff["layout_changed"], true);
3558        assert_eq!(diff["rules_changed"], true);
3559        assert_eq!(diff["solver_status_changed"], true);
3560        let details = diff["assignment_details"].as_array().unwrap();
3561        assert_eq!(details.len(), 3);
3562        assert_eq!(
3563            details[0],
3564            json!({"student_ref": "student-1", "change": "moved",
3565                   "before_seat_id": "R1C1", "after_seat_id": "R1C2"})
3566        );
3567        assert_eq!(
3568            details[1],
3569            json!({"student_ref": "student-2", "change": "unseated",
3570                   "before_seat_id": "R1C2", "after_seat_id": Value::Null})
3571        );
3572        assert_eq!(
3573            details[2],
3574            json!({"student_ref": "student-3", "change": "seated",
3575                   "before_seat_id": Value::Null, "after_seat_id": "R1C1"})
3576        );
3577
3578        // Privacy: the compare payload never contains student data — only
3579        // anonymized student-N references, counts and seat ids.
3580        let serialized = serde_json::to_string(&value).unwrap();
3581        for secret in ["Alice", "Bob", "Carol", "student_key", "student_name"] {
3582            assert!(
3583                !serialized.contains(secret),
3584                "compare response leaked {secret:?}: {serialized}"
3585            );
3586        }
3587    }
3588
3589    #[test]
3590    fn artifact_compare_supports_candidate_set_and_rotation_plan_kinds() {
3591        let root = test_web_root();
3592        let project = ArtifactProject::new("compare-kinds");
3593        let c1 = project.write("outputs/candidates-a.json", CANDIDATE_SET_LEFT);
3594        let c2 = project.write("outputs/candidates-b.json", CANDIDATE_SET_RIGHT);
3595        let r1 = project.write("outputs/rotation-a.json", ROTATION_PLAN_LEFT);
3596        let r2 = project.write("outputs/rotation-b.json", ROTATION_PLAN_RIGHT);
3597
3598        // candidate_set vs candidate_set: the recommended inner snapshot
3599        // drives the diff.
3600        let response = project.compare(&c1, &c2, &root);
3601        assert_eq!(
3602            response.status,
3603            200,
3604            "body: {}",
3605            String::from_utf8_lossy(&response.body)
3606        );
3607        let value = body_json(&response);
3608        assert_eq!(value["left"]["kind"], "candidate_set");
3609        assert_eq!(value["right"]["kind"], "candidate_set");
3610        assert_eq!(value["left"]["student_count"], 1);
3611        assert_eq!(value["diff"]["assignment_changes"], 1);
3612        assert_eq!(value["diff"]["assignment_details"][0]["change"], "moved");
3613        assert!(!serde_json::to_string(&value).unwrap().contains("Alice"));
3614
3615        // rotation_plan vs rotation_plan: first period snapshot drives the
3616        // diff, no crash.
3617        let response = project.compare(&r1, &r2, &root);
3618        assert_eq!(
3619            response.status,
3620            200,
3621            "body: {}",
3622            String::from_utf8_lossy(&response.body)
3623        );
3624        let value = body_json(&response);
3625        assert_eq!(value["left"]["kind"], "rotation_plan");
3626        assert_eq!(value["right"]["kind"], "rotation_plan");
3627        assert_eq!(value["diff"]["assignment_changes"], 1);
3628        assert_eq!(value["diff"]["assignment_details"][0]["change"], "moved");
3629
3630        // Cross-kind snapshot vs candidate_set is a normal comparison.
3631        let snap = project.write("history/snap.json", SNAPSHOT_LEFT);
3632        let response = project.compare(&snap, &c1, &root);
3633        assert_eq!(
3634            response.status,
3635            200,
3636            "body: {}",
3637            String::from_utf8_lossy(&response.body)
3638        );
3639        let value = body_json(&response);
3640        assert_eq!(value["left"]["kind"], "snapshot");
3641        assert_eq!(value["right"]["kind"], "candidate_set");
3642        assert!(value["diff"]["assignment_changes"].as_u64().is_some());
3643    }
3644
3645    #[test]
3646    fn artifact_compare_rejects_self_comparison() {
3647        let root = test_web_root();
3648        let project = ArtifactProject::new("compare-self");
3649        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3650        let response = project.compare(&left, &left, &root);
3651        assert_error_envelope(&response, 422, "compared with itself");
3652    }
3653
3654    #[test]
3655    fn artifact_compare_rejects_self_comparison_through_path_aliases() {
3656        // `..` aliases must not bypass the self-compare rejection: the
3657        // containment resolver canonicalizes both sides first (M1-05).
3658        let root = test_web_root();
3659        let project = ArtifactProject::new("compare-self-alias");
3660        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3661        let alias = project.dir.join("history/../history/a.json");
3662        let response = project.compare(&left, &alias, &root);
3663        assert_error_envelope(&response, 422, "compared with itself");
3664    }
3665
3666    #[test]
3667    fn artifact_compare_missing_artifact_is_clean_4xx() {
3668        let root = test_web_root();
3669        let project = ArtifactProject::new("compare-missing-artifact");
3670        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3671        let missing = project.dir.join("history/never-written.json");
3672        let response = project.compare(&left, &missing, &root);
3673        assert_error_envelope(&response, 422, "history or outputs directory");
3674    }
3675
3676    #[test]
3677    fn artifact_compare_missing_project_is_404() {
3678        let root = test_web_root();
3679        let project = ArtifactProject::new("compare-missing-project");
3680        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3681        let right = project.write("history/b.json", SNAPSHOT_RIGHT);
3682        let body = serde_json::to_vec(&json!({
3683            "project_path": project.dir.join("no-such/project.json"),
3684            "artifact_path": left.to_string_lossy().into_owned(),
3685            "compare_to_path": right.to_string_lossy().into_owned(),
3686        }))
3687        .unwrap();
3688        let response = route_one(
3689            &request("POST", "/api/v1/projects/artifacts/compare", &body),
3690            &root,
3691        );
3692        assert_error_envelope(&response, 404, "Project file not found");
3693    }
3694
3695    #[test]
3696    fn artifact_compare_invalid_json_is_422() {
3697        let root = test_web_root();
3698        let project = ArtifactProject::new("compare-invalid-json");
3699        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3700        let garbage = project.write("history/garbage.json", "this is not json {{{");
3701        let response = project.compare(&left, &garbage, &root);
3702        assert_error_envelope(&response, 422, "Invalid JSON");
3703    }
3704
3705    #[test]
3706    fn artifact_compare_unsupported_kind_is_422() {
3707        let root = test_web_root();
3708        let project = ArtifactProject::new("compare-unsupported-kind");
3709        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3710        // A project document is a valid JSON file but not a comparable
3711        // artifact kind.
3712        let response = project.compare(&project.project_file, &left, &root);
3713        assert_error_envelope(&response, 422, "Unsupported project artifact kind");
3714    }
3715
3716    #[test]
3717    fn artifact_compare_bad_request_bodies_are_400() {
3718        let root = test_web_root();
3719        let project = ArtifactProject::new("compare-bad-body");
3720        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3721
3722        // Missing compare_to_path.
3723        let body = serde_json::to_vec(&json!({
3724            "project_path": project.project_file.to_string_lossy().into_owned(),
3725            "artifact_path": left.to_string_lossy().into_owned(),
3726        }))
3727        .unwrap();
3728        let response = route_one(
3729            &request("POST", "/api/v1/projects/artifacts/compare", &body),
3730            &root,
3731        );
3732        assert_error_envelope(&response, 400, "compare_to_path");
3733
3734        // Missing artifact_path.
3735        let body = serde_json::to_vec(&json!({
3736            "project_path": project.project_file.to_string_lossy().into_owned(),
3737        }))
3738        .unwrap();
3739        let response = route_one(
3740            &request("POST", "/api/v1/projects/artifacts/compare", &body),
3741            &root,
3742        );
3743        assert_error_envelope(&response, 400, "artifact_path");
3744
3745        // Non-JSON body.
3746        let response = route_one(
3747            &request("POST", "/api/v1/projects/artifacts/compare", b"not json"),
3748            &root,
3749        );
3750        assert_error_envelope(&response, 400, "not valid JSON");
3751
3752        // Empty body.
3753        let response = route_one(
3754            &request("POST", "/api/v1/projects/artifacts/compare", b""),
3755            &root,
3756        );
3757        assert_error_envelope(&response, 400, "empty request body");
3758    }
3759
3760    /// Parity-gap contract (M1-05 workspace containment): the Python oracle
3761    /// `_resolve_project_artifact` (handlers.py:1332) rejects artifact paths
3762    /// outside the project workspace with 422 ("The artifact must be a file
3763    /// inside the project history or outputs directory."). The Rust io layer
3764    /// (`compare_artifacts_json`, projects.rs:1786) does not yet enforce that
3765    /// containment, so the current server contract accepts the file and
3766    /// returns a full diff. This locks the CURRENT behavior; when the M5
3767    /// containment fix lands, flip these assertions to 422.
3768    #[test]
3769    fn artifact_compare_rejects_files_outside_project_workspace() {
3770        let root = test_web_root();
3771        let project = ArtifactProject::new("compare-outside");
3772        let left = project.write("history/a.json", SNAPSHOT_LEFT);
3773        // A perfectly valid snapshot artifact that lives outside the project
3774        // workspace (neither history_dir nor outputs_dir).
3775        let outside_dir = std::env::temp_dir().join(format!(
3776            "seattrellis_artifact_outside_{}_{}",
3777            std::process::id(),
3778            TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed)
3779        ));
3780        let _ = fs::remove_dir_all(&outside_dir);
3781        fs::create_dir_all(&outside_dir).unwrap();
3782        let outside = outside_dir.join("leaked.snapshot.json");
3783        fs::write(&outside, SNAPSHOT_RIGHT).unwrap();
3784
3785        let response = project.compare(&left, &outside, &root);
3786        assert_eq!(
3787            response.status,
3788            422,
3789            "outside-workspace artifacts must be rejected (Python oracle); body: {}",
3790            String::from_utf8_lossy(&response.body)
3791        );
3792        assert!(
3793            String::from_utf8_lossy(&response.body).contains("history or outputs directory"),
3794            "body: {}",
3795            String::from_utf8_lossy(&response.body)
3796        );
3797        let _ = fs::remove_dir_all(&outside_dir);
3798    }
3799
3800    #[test]
3801    fn artifact_restore_creates_snapshot_with_provenance_metadata() {
3802        let root = test_web_root();
3803        let project = ArtifactProject::new("restore-provenance");
3804        let source = project.write("history/plan.snapshot.json", SNAPSHOT_LEFT);
3805
3806        let response = project.restore(&source, &root);
3807        assert_eq!(
3808            response.status,
3809            200,
3810            "body: {}",
3811            String::from_utf8_lossy(&response.body)
3812        );
3813        assert_eq!(
3814            response.content_type,
3815            Some("application/json; charset=utf-8")
3816        );
3817        let value = body_json(&response);
3818
3819        // Client envelope (`ProjectArtifactRestoreResponse` in types.ts).
3820        assert_eq!(value["api_version"], "1");
3821        assert!(value["project_path"]
3822            .as_str()
3823            .unwrap()
3824            .ends_with("project.json"));
3825        assert!(value["source_artifact"]
3826            .as_str()
3827            .unwrap()
3828            .ends_with("plan.snapshot.json"));
3829        let restored = value["restored_artifact"].as_str().unwrap();
3830        assert!(
3831            restored.ends_with("restored-plan.snapshot.json"),
3832            "{restored}"
3833        );
3834        assert!(Path::new(restored).is_file());
3835        // The restored artifact lands in the project outputs directory.
3836        assert_eq!(
3837            Path::new(restored)
3838                .parent()
3839                .and_then(|parent| parent.file_name())
3840                .map(|name| name.to_string_lossy().into_owned()),
3841            Some("outputs".to_string())
3842        );
3843
3844        // Provenance metadata exists and points at the source.
3845        let document: Value = serde_json::from_str(&fs::read_to_string(restored).unwrap()).unwrap();
3846        assert_eq!(document["metadata"]["restored_from"], "plan.snapshot.json");
3847        assert!(document["metadata"]["restored_at"]
3848            .as_str()
3849            .is_some_and(|value| value.ends_with("+00:00")));
3850        assert!(document.get("restored_at").is_none());
3851        assert!(document.get("kind").is_none());
3852        // The assignment content survived the restore.
3853        assert_eq!(document["assignments"].as_array().unwrap().len(), 2);
3854        assert_eq!(document["assignments"][0]["seat_id"], "R1C1");
3855
3856        // The response carries paths only, never student data.
3857        let serialized = serde_json::to_string(&value).unwrap();
3858        assert!(!serialized.contains("Alice"), "{serialized}");
3859    }
3860
3861    #[test]
3862    fn artifact_restore_twice_never_overwrites() {
3863        let root = test_web_root();
3864        let project = ArtifactProject::new("restore-twice");
3865        let source = project.write("history/plan.snapshot.json", SNAPSHOT_LEFT);
3866
3867        let first = body_json(&project.restore(&source, &root));
3868        let second = body_json(&project.restore(&source, &root));
3869
3870        let first_path = first["restored_artifact"].as_str().unwrap();
3871        let second_path = second["restored_artifact"].as_str().unwrap();
3872        assert_ne!(
3873            first_path, second_path,
3874            "restore must never overwrite an existing snapshot"
3875        );
3876        assert!(
3877            second_path.ends_with("restored-plan-2.snapshot.json"),
3878            "{second_path}"
3879        );
3880        assert!(Path::new(first_path).is_file());
3881        assert!(Path::new(second_path).is_file());
3882        // Both copies carry the same provenance metadata.
3883        let first_doc: Value =
3884            serde_json::from_str(&fs::read_to_string(first_path).unwrap()).unwrap();
3885        let second_doc: Value =
3886            serde_json::from_str(&fs::read_to_string(second_path).unwrap()).unwrap();
3887        assert_eq!(first_doc["metadata"]["restored_from"], "plan.snapshot.json");
3888        assert_eq!(
3889            second_doc["metadata"]["restored_from"],
3890            "plan.snapshot.json"
3891        );
3892    }
3893
3894    #[test]
3895    fn artifact_restore_rejects_rotation_plan() {
3896        let root = test_web_root();
3897        let project = ArtifactProject::new("restore-rotation");
3898        let rotation = project.write("outputs/rotation.json", ROTATION_PLAN_LEFT);
3899        let response = project.restore(&rotation, &root);
3900        assert_error_envelope(&response, 422, "rotation plan");
3901        // No partial artifact may be written to outputs.
3902        assert!(!project
3903            .dir
3904            .join("outputs/restored-rotation.snapshot.json")
3905            .exists());
3906    }
3907
3908    #[test]
3909    fn artifact_restore_rejects_non_snapshot_kind() {
3910        let root = test_web_root();
3911        let project = ArtifactProject::new("restore-project-kind");
3912        // A project document is a valid JSON file but not a restorable kind.
3913        let response = project.restore(&project.project_file, &root);
3914        assert_error_envelope(&response, 422, "Unsupported project artifact kind");
3915        assert!(!project.dir.join("outputs").exists());
3916    }
3917
3918    #[test]
3919    fn artifact_restore_candidate_set_writes_provenance_snapshot() {
3920        let root = test_web_root();
3921        let project = ArtifactProject::new("restore-candidate");
3922        let source = project.write("outputs/candidates-a.json", CANDIDATE_SET_LEFT);
3923
3924        let response = project.restore(&source, &root);
3925        assert_eq!(
3926            response.status,
3927            200,
3928            "body: {}",
3929            String::from_utf8_lossy(&response.body)
3930        );
3931        let value = body_json(&response);
3932        let restored = value["restored_artifact"].as_str().unwrap();
3933        assert!(
3934            restored.ends_with("restored-candidates-a.snapshot.json"),
3935            "{restored}"
3936        );
3937        assert!(Path::new(restored).is_file());
3938
3939        let document: Value = serde_json::from_str(&fs::read_to_string(restored).unwrap()).unwrap();
3940        assert_eq!(document["metadata"]["restored_from"], "candidates-a.json");
3941        assert!(document["metadata"]["restored_at"]
3942            .as_str()
3943            .is_some_and(|value| value.ends_with("+00:00")));
3944        // Python restores a candidate set's recommended candidate as a fresh
3945        // SeatingSnapshot rather than preserving the candidate-set envelope.
3946        assert!(document.get("kind").is_none());
3947        assert!(document.get("candidates").is_none());
3948        assert_eq!(document["assignments"][0]["seat_id"], "R1C1");
3949    }
3950
3951    #[test]
3952    fn artifact_restore_missing_artifact_is_clean_4xx() {
3953        let root = test_web_root();
3954        let project = ArtifactProject::new("restore-missing-artifact");
3955        let missing = project.dir.join("history/never-written.json");
3956        let response = project.restore(&missing, &root);
3957        assert_error_envelope(&response, 422, "history or outputs directory");
3958        assert!(!project.dir.join("outputs").exists());
3959    }
3960
3961    #[test]
3962    fn artifact_restore_missing_project_is_404() {
3963        let root = test_web_root();
3964        let project = ArtifactProject::new("restore-missing-project");
3965        let source = project.write("history/a.json", SNAPSHOT_LEFT);
3966        let body = serde_json::to_vec(&json!({
3967            "project_path": project.dir.join("no-such/project.json"),
3968            "artifact_path": source.to_string_lossy().into_owned(),
3969        }))
3970        .unwrap();
3971        let response = route_one(
3972            &request("POST", "/api/v1/projects/artifacts/restore", &body),
3973            &root,
3974        );
3975        assert_error_envelope(&response, 404, "Project file not found");
3976    }
3977
3978    #[test]
3979    fn artifact_restore_invalid_json_is_422() {
3980        let root = test_web_root();
3981        let project = ArtifactProject::new("restore-invalid-json");
3982        let garbage = project.write("history/garbage.json", "this is not json {{{");
3983        let response = project.restore(&garbage, &root);
3984        assert_error_envelope(&response, 422, "Invalid JSON");
3985        assert!(!project.dir.join("outputs").exists());
3986    }
3987
3988    #[test]
3989    fn artifact_restore_bad_request_bodies_are_400() {
3990        let root = test_web_root();
3991        let project = ArtifactProject::new("restore-bad-body");
3992
3993        // Missing artifact_path.
3994        let body = serde_json::to_vec(&json!({
3995            "project_path": project.project_file.to_string_lossy().into_owned(),
3996        }))
3997        .unwrap();
3998        let response = route_one(
3999            &request("POST", "/api/v1/projects/artifacts/restore", &body),
4000            &root,
4001        );
4002        assert_error_envelope(&response, 400, "artifact_path");
4003
4004        // Non-JSON body.
4005        let response = route_one(
4006            &request("POST", "/api/v1/projects/artifacts/restore", b"not json"),
4007            &root,
4008        );
4009        assert_error_envelope(&response, 400, "not valid JSON");
4010
4011        // Empty body.
4012        let response = route_one(
4013            &request("POST", "/api/v1/projects/artifacts/restore", b""),
4014            &root,
4015        );
4016        assert_error_envelope(&response, 400, "empty request body");
4017    }
4018
4019    /// M1-05 workspace containment: restore sources outside the project
4020    /// workspace are rejected with 422, matching the Python oracle
4021    /// (`_resolve_project_artifact`, handlers.py:1332).
4022    #[test]
4023    fn artifact_restore_rejects_files_outside_project_workspace() {
4024        let root = test_web_root();
4025        let project = ArtifactProject::new("restore-outside");
4026        // A perfectly valid snapshot artifact outside the project workspace.
4027        let outside_dir = std::env::temp_dir().join(format!(
4028            "seattrellis_artifact_outside_{}_{}",
4029            std::process::id(),
4030            TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed)
4031        ));
4032        let _ = fs::remove_dir_all(&outside_dir);
4033        fs::create_dir_all(&outside_dir).unwrap();
4034        let outside = outside_dir.join("leaked.snapshot.json");
4035        fs::write(&outside, SNAPSHOT_LEFT).unwrap();
4036
4037        let response = project.restore(&outside, &root);
4038        assert_eq!(
4039            response.status,
4040            422,
4041            "outside-workspace artifacts must be rejected (Python oracle); body: {}",
4042            String::from_utf8_lossy(&response.body)
4043        );
4044        assert!(
4045            String::from_utf8_lossy(&response.body).contains("history or outputs directory"),
4046            "body: {}",
4047            String::from_utf8_lossy(&response.body)
4048        );
4049        let _ = fs::remove_dir_all(&outside_dir);
4050    }
4051
4052    #[test]
4053    fn generate_frontend_class_request_unknown_room_is_422() {
4054        let root = test_web_root();
4055        let problem = json!({
4056            "draft": {
4057                "name": "X",
4058                "students": [{"student_id": "S1", "name": "Alice"}],
4059                "room": {"template_id": "standard-99"},
4060                "goal": {"goal_id": "daily-rotation"}
4061            }
4062        });
4063        let body = serde_json::to_vec(&problem).unwrap();
4064        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4065        assert_eq!(
4066            response.status,
4067            422,
4068            "body: {}",
4069            String::from_utf8_lossy(&response.body)
4070        );
4071        let value = body_json(&response);
4072        assert_eq!(value["error"], "room_not_found");
4073        assert!(value["message"].as_str().unwrap().contains("standard-99"));
4074    }
4075
4076    /// An unknown goal id on the frontend path is a 422.
4077    #[test]
4078    fn generate_frontend_class_request_unknown_goal_is_422() {
4079        let root = test_web_root();
4080        let problem = json!({
4081            "draft": {
4082                "name": "X",
4083                "students": [{"student_id": "S1", "name": "Alice"}],
4084                "room": {"template_id": "standard-30"},
4085                "goal": {"goal_id": "warp-speed"}
4086            }
4087        });
4088        let body = serde_json::to_vec(&problem).unwrap();
4089        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4090        assert_eq!(
4091            response.status,
4092            422,
4093            "body: {}",
4094            String::from_utf8_lossy(&response.body)
4095        );
4096        let value = body_json(&response);
4097        assert_eq!(value["error"], "unknown_goal");
4098        assert!(value["message"].as_str().unwrap().contains("warp-speed"));
4099    }
4100
4101    /// `rules_overlay.groups` must reach the solver: a `together` group is
4102    /// seated adjacently and a `separate` group is kept apart.
4103    #[test]
4104    fn frontend_rules_overlay_groups_reach_the_solver() {
4105        let root = test_web_root();
4106        let problem = json!({
4107            "draft": {
4108                "name": "Groups",
4109                "students": [
4110                    {"student_id": "S1", "name": "Ann"},
4111                    {"student_id": "S2", "name": "Ben"},
4112                    {"student_id": "S3", "name": "Cid"},
4113                    {"student_id": "S4", "name": "Dee"}
4114                ],
4115                "room": {"layout": line_of_four_layout()},
4116                "goal": {
4117                    "goal_id": "quick-shuffle",
4118                    "rules_overlay": {
4119                        "groups": [
4120                            {"name": "buddy", "students": ["S1", "S2"], "together": true},
4121                            {"name": "rival", "students": ["S3", "S4"], "separate": true}
4122                        ]
4123                    }
4124                }
4125            }
4126        });
4127        let body = serde_json::to_vec(&problem).unwrap();
4128        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4129        assert_eq!(
4130            response.status,
4131            200,
4132            "body: {}",
4133            String::from_utf8_lossy(&response.body)
4134        );
4135        let editor = body_json(&response)["editor"].clone();
4136        let coords = |key: &str| editor_seat_coords(&editor, key).expect("seated student");
4137        let (row_a, col_a) = coords("S1");
4138        let (row_b, col_b) = coords("S2");
4139        let (row_c, col_c) = coords("S3");
4140        let (row_d, col_d) = coords("S4");
4141        assert_eq!(row_a, row_b, "S1 and S2 share a row");
4142        assert_eq!((col_a - col_b).abs(), 1, "S1 and S2 must sit together");
4143        assert!(
4144            row_c != row_d || (col_c - col_d).abs() != 1,
4145            "S3 and S4 must sit apart"
4146        );
4147    }
4148
4149    /// `hard_rules` (fixed seat + adjacency pairs) must be resolved from
4150    /// student keys and seat ids into enforced index pairs.
4151    #[test]
4152    fn frontend_hard_rules_are_resolved_and_enforced() {
4153        let root = test_web_root();
4154        let problem = json!({
4155            "draft": {
4156                "name": "Pinned",
4157                "students": [
4158                    {"student_id": "S1", "name": "Ann"},
4159                    {"student_id": "S2", "name": "Ben"},
4160                    {"student_id": "S3", "name": "Cid"},
4161                    {"student_id": "S4", "name": "Dee"}
4162                ],
4163                "room": {"layout": line_of_four_layout()},
4164                "goal": {
4165                    "goal_id": "quick-shuffle",
4166                    "hard_rules": {
4167                        "fixed_seats": [{"student": "S1", "seat_id": "P4"}],
4168                        "must_be_adjacent": [{"students": ["S2", "S3"]}]
4169                    }
4170                }
4171            }
4172        });
4173        let body = serde_json::to_vec(&problem).unwrap();
4174        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4175        assert_eq!(
4176            response.status,
4177            200,
4178            "body: {}",
4179            String::from_utf8_lossy(&response.body)
4180        );
4181        let editor = body_json(&response)["editor"].clone();
4182        let coords = |key: &str| editor_seat_coords(&editor, key).expect("seated student");
4183        assert_eq!(coords("S1"), (1, 4), "S1 is pinned to P4");
4184        let (row_b, col_b) = coords("S2");
4185        let (row_c, col_c) = coords("S3");
4186        assert_eq!(row_b, row_c, "S2 and S3 share a row");
4187        assert_eq!((col_b - col_c).abs(), 1, "S2 and S3 must sit adjacent");
4188    }
4189
4190    /// A custom `draft.room.layout` (the React room builder) must be accepted
4191    /// and drive the grid instead of a template id.
4192    #[test]
4193    fn frontend_custom_layout_generates() {
4194        let root = test_web_root();
4195        let problem = json!({
4196            "draft": {
4197                "name": "Custom room",
4198                "students": [
4199                    {"student_id": "S1", "name": "Ann"},
4200                    {"student_id": "S2", "name": "Ben"}
4201                ],
4202                "room": {"layout": line_of_four_layout()},
4203                "goal": {"goal_id": "quick-shuffle"}
4204            }
4205        });
4206        let body = serde_json::to_vec(&problem).unwrap();
4207        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4208        assert_eq!(
4209            response.status,
4210            200,
4211            "body: {}",
4212            String::from_utf8_lossy(&response.body)
4213        );
4214        let value = body_json(&response);
4215        assert_eq!(value["editor"]["seats"].as_array().map(Vec::len), Some(4));
4216    }
4217
4218    /// The custom goal requires `custom_rules`; a full document is accepted and
4219    /// a missing one is a 422.
4220    #[test]
4221    fn frontend_custom_goal_requires_custom_rules() {
4222        let root = test_web_root();
4223        let missing = json!({
4224            "draft": {
4225                "name": "Custom",
4226                "students": [
4227                    {"student_id": "S1", "name": "Ann"},
4228                    {"student_id": "S2", "name": "Ben"}
4229                ],
4230                "room": {"template_id": "standard-30"},
4231                "goal": {"goal_id": "custom"}
4232            }
4233        });
4234        let body = serde_json::to_vec(&missing).unwrap();
4235        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4236        assert_eq!(
4237            response.status, 422,
4238            "custom goal without rules must be a 422"
4239        );
4240        assert_eq!(body_json(&response)["error"], "invalid_class_draft");
4241
4242        let with_rules = json!({
4243            "draft": {
4244                "name": "Custom",
4245                "students": [
4246                    {"student_id": "S1", "name": "Ann", "score": 90},
4247                    {"student_id": "S2", "name": "Ben", "score": 70}
4248                ],
4249                "room": {"template_id": "standard-30"},
4250                "goal": {
4251                    "goal_id": "custom",
4252                    "custom_rules": {
4253                        "seed": 1,
4254                        "soft": {
4255                            "vision_front": {"enabled": true, "weight": 20},
4256                            "randomize": {"enabled": true, "weight": 1}
4257                        }
4258                    }
4259                }
4260            }
4261        });
4262        let body = serde_json::to_vec(&with_rules).unwrap();
4263        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4264        assert_eq!(
4265            response.status,
4266            200,
4267            "body: {}",
4268            String::from_utf8_lossy(&response.body)
4269        );
4270    }
4271
4272    /// A hard rule that names an unknown student must be a 422, not silently
4273    /// dropped.
4274    #[test]
4275    fn frontend_hard_rule_unknown_student_is_422() {
4276        let root = test_web_root();
4277        let problem = json!({
4278            "draft": {
4279                "name": "Bad",
4280                "students": [
4281                    {"student_id": "S1", "name": "Ann"},
4282                    {"student_id": "S2", "name": "Ben"}
4283                ],
4284                "room": {"layout": line_of_four_layout()},
4285                "goal": {
4286                    "goal_id": "quick-shuffle",
4287                    "hard_rules": {
4288                        "fixed_seats": [{"student": "GHOST", "seat_id": "P1"}]
4289                    }
4290                }
4291            }
4292        });
4293        let body = serde_json::to_vec(&problem).unwrap();
4294        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4295        assert_eq!(
4296            response.status, 422,
4297            "unknown hard-rule student must be a 422"
4298        );
4299        let value = body_json(&response);
4300        assert_eq!(value["error"], "invalid_class_draft");
4301        assert!(value["message"].as_str().unwrap().contains("GHOST"));
4302    }
4303
4304    /// `history_snapshots` must be forwarded as core `history` + `pair_history`
4305    /// so fair_rotation and recent-neighbor costs see past placements.
4306    #[test]
4307    fn history_snapshots_forward_fair_rotation_and_pair_data() {
4308        let grid = seattrellis_domain::room_templates::grid_from_layout(&line_of_four_layout())
4309            .expect("line layout is valid");
4310        let students: Vec<Value> = json!([{ "key": "S1" }, { "key": "S2" }])
4311            .as_array()
4312            .unwrap()
4313            .clone();
4314        let snapshots: Vec<Value> = json!([{
4315            "schema_version": "1",
4316            "assignments": [
4317                {"student_key": "S1", "seat_id": "P1"},
4318                {"student_key": "S2", "seat_id": "P2"}
4319            ]
4320        }])
4321        .as_array()
4322        .unwrap()
4323        .clone();
4324
4325        let (history, pair_history) =
4326            seattrellis_application::class_generation::build_history_json(
4327                &students, &grid, &snapshots,
4328            )
4329            .expect("snapshots build");
4330        assert_eq!(history["history_count"], 1);
4331        let s1 = &history["students"]["S1"];
4332        assert_eq!(s1["records"].as_array().map(Vec::len), Some(1));
4333        let s1_categories = s1["records"][0]["categories"].as_array().unwrap();
4334        // P1 is col 1 in the single-row layout: side + corner (no zones, so the
4335        // single row is inferred as "middle" rather than "front").
4336        for expected in ["side", "corner"] {
4337            assert!(
4338                s1_categories.iter().any(|category| category == expected),
4339                "S1 (P1) should include {expected}: {s1_categories:?}"
4340            );
4341        }
4342        for category in s1_categories {
4343            assert_eq!(
4344                s1["category_counts"][category.as_str().unwrap()],
4345                json!(1),
4346                "category_counts must agree with records"
4347            );
4348        }
4349        // S1 (P1) and S2 (P2) sit side by side, so their pair relation is
4350        // recorded and the recent-neighbor cost can penalize a repeat.
4351        assert_eq!(pair_history["history_count"], 1);
4352        let pair = &pair_history["pairs"]["S1|S2"];
4353        assert!(
4354            pair.is_object(),
4355            "adjacent S1/S2 must appear in pair history"
4356        );
4357        let relations = pair["records"][0]["relations"].as_array().unwrap();
4358        assert!(
4359            relations.iter().any(|relation| relation == "desk_mate"),
4360            "side-by-side seats are desk mates: {relations:?}"
4361        );
4362    }
4363
4364    /// A frontend request carrying history_snapshots must still generate (the
4365    /// history is forwarded, not rejected).
4366    #[test]
4367    fn frontend_history_snapshots_do_not_break_generation() {
4368        let root = test_web_root();
4369        let problem = json!({
4370            "draft": {
4371                "name": "History",
4372                "students": [
4373                    {"student_id": "S1", "name": "Ann", "score": 92},
4374                    {"student_id": "S2", "name": "Ben", "score": 84}
4375                ],
4376                "room": {"layout": line_of_four_layout()},
4377                "goal": {"goal_id": "daily-rotation"},
4378                "history_snapshots": [{
4379                    "schema_version": "1",
4380                    "assignments": [
4381                        {"student_key": "S1", "seat_id": "P1"},
4382                        {"student_key": "S2", "seat_id": "P2"}
4383                    ]
4384                }]
4385            }
4386        });
4387        let body = serde_json::to_vec(&problem).unwrap();
4388        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4389        assert_eq!(
4390            response.status,
4391            200,
4392            "body: {}",
4393            String::from_utf8_lossy(&response.body)
4394        );
4395        let value = body_json(&response);
4396        assert_eq!(value["goal"]["goal_id"], "daily-rotation");
4397        assert_eq!(
4398            value["editor"]["students"].as_array().map(Vec::len),
4399            Some(2)
4400        );
4401    }
4402
4403    #[test]
4404    fn solve_constraint_failure_is_a_normal_domain_result() {
4405        let root = test_web_root();
4406        // Two students that must sit adjacent, but the graph has no edges at
4407        // all, so the greedy cannot satisfy the adjacency requirement.
4408        let problem = json!({
4409            "api_version": 2,
4410            "student_count": 2,
4411            "seat_positions": [[1.0,1.0],[2.0,1.0]],
4412            "must_be_adjacent": [[0, 1]]
4413        });
4414        let body = serde_json::to_vec(&problem).unwrap();
4415        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4416        assert_eq!(response.status, 200);
4417        let value = body_json(&response);
4418        assert_eq!(value["feasible"], false);
4419        assert!(value["editor"].is_null());
4420        assert!(value["recommended_candidate_id"].is_null());
4421        assert!(value["candidates"].as_array().is_some_and(Vec::is_empty));
4422        assert_eq!(value["message_key"], "solve.plan_not_found");
4423        assert!(matches!(
4424            value["status"].as_str(),
4425            Some("ProvenInfeasible" | "Timeout" | "Unknown" | "Cancelled")
4426        ));
4427    }
4428
4429    #[test]
4430    fn v2_solve_returns_domain_status_without_creating_transport_errors() {
4431        let root = test_web_root();
4432        let problem = json!({
4433            "api_version": 2,
4434            "student_count": 2,
4435            "seat_positions": [[1.0, 1.0], [2.0, 1.0]],
4436            "must_be_adjacent": [[0, 1]]
4437        });
4438        let response = route_one(
4439            &request(
4440                "POST",
4441                "/api/v2/solve",
4442                &serde_json::to_vec(&problem).unwrap(),
4443            ),
4444            &root,
4445        );
4446        assert_eq!(response.status, 200);
4447        let value = body_json(&response);
4448        assert_eq!(value["feasible"], false);
4449        assert!(value["assignment"].as_array().is_some_and(Vec::is_empty));
4450        assert!(matches!(
4451            value["status"].as_str(),
4452            Some("ProvenInfeasible" | "Timeout" | "Unknown" | "Cancelled")
4453        ));
4454    }
4455
4456    #[test]
4457    fn v2_solve_invalid_input_uses_the_structured_error_envelope() {
4458        let root = test_web_root();
4459        let problem = json!({
4460            "api_version": 99,
4461            "student_count": 1,
4462            "seat_positions": [[1.0, 1.0]]
4463        });
4464        let response = route_one(
4465            &request(
4466                "POST",
4467                "/api/v2/solve",
4468                &serde_json::to_vec(&problem).unwrap(),
4469            ),
4470            &root,
4471        );
4472        assert_eq!(response.status, 400);
4473        let value = body_json(&response);
4474        assert_eq!(value["code"], "invalid_solve_request");
4475        assert_eq!(value["status"], "InvalidInput");
4476        assert_eq!(value["recoverable"], true);
4477        assert_eq!(value["suggested_action"], "review_input");
4478        assert!(value["message_key"].as_str().is_some());
4479    }
4480
4481    #[test]
4482    fn solve_invalid_request_carries_frozen_status() {
4483        let root = test_web_root();
4484        // Unsupported api_version is a validation failure: 400 + InvalidInput.
4485        let problem = json!({
4486            "api_version": 99,
4487            "student_count": 2,
4488            "seat_positions": [[1.0,1.0],[2.0,1.0]]
4489        });
4490        let body = serde_json::to_vec(&problem).unwrap();
4491        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4492        assert_eq!(response.status, 400);
4493        let value = body_json(&response);
4494        assert_eq!(value["status"], "InvalidInput");
4495    }
4496
4497    #[test]
4498    fn solve_invalid_json_is_400() {
4499        let root = test_web_root();
4500        let response = route_one(
4501            &request("POST", "/api/v1/classes/generate", b"not json at all"),
4502            &root,
4503        );
4504        assert_eq!(response.status, 400);
4505        assert!(body_json(&response)["error"].is_string());
4506    }
4507
4508    #[test]
4509    fn solve_too_many_students_is_400() {
4510        let root = test_web_root();
4511        let problem = json!({
4512            "api_version": 2,
4513            "student_count": 10,
4514            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0]]
4515        });
4516        let body = serde_json::to_vec(&problem).unwrap();
4517        let response = route_one(&request("POST", "/api/v1/classes/generate", &body), &root);
4518        assert_eq!(response.status, 400);
4519        let value = body_json(&response);
4520        assert_eq!(value["error"], "invalid_solve_request");
4521        assert_eq!(value["status"], "InvalidInput");
4522        assert!(value["message"]
4523            .as_str()
4524            .unwrap()
4525            .contains("cannot seat more students"));
4526    }
4527
4528    #[test]
4529    fn roster_upload_preview_get_delete_flow() {
4530        let root = test_web_root();
4531        let csv = b"student_id,name,gender,height_cm,score,vision\nS1,Alice,F,160,90,0.8\nS2,Bob,M,165,81,0.6\nS3,Carol,F,150,75,1.0\n";
4532        let body = multipart_body(csv, "roster.csv", "----testboundary");
4533        let response = route_one(
4534            &request_with_content_type(
4535                "POST",
4536                "/api/v1/rosters/drafts",
4537                &body,
4538                Some("multipart/form-data; boundary=----testboundary"),
4539            ),
4540            &root,
4541        );
4542        assert_eq!(
4543            response.status,
4544            200,
4545            "body: {}",
4546            String::from_utf8_lossy(&response.body)
4547        );
4548        let roster = body_json(&response);
4549        assert_eq!(roster["source_format"], "csv");
4550        assert_eq!(roster["row_count"], 3);
4551        assert_eq!(roster["column_count"], 6);
4552        let roster_id = roster["draft_id"].as_str().unwrap().to_string();
4553
4554        let get = route_one(
4555            &request("GET", &format!("/api/v1/rosters/drafts/{roster_id}"), b""),
4556            &root,
4557        );
4558        assert_eq!(get.status, 200);
4559        assert_eq!(body_json(&get)["draft_id"], roster_id);
4560
4561        let mapping = roster["suggested_mapping"].clone();
4562        let preview_body = json!({
4563            "mapping": mapping,
4564            "mode": "incremental",
4565            "current_students": [],
4566            "current_revision": 0,
4567            "updated_fields": ["name"]
4568        });
4569        let preview = route_one(
4570            &request(
4571                "POST",
4572                &format!("/api/v1/rosters/drafts/{roster_id}/preview"),
4573                &serde_json::to_vec(&preview_body).unwrap(),
4574            ),
4575            &root,
4576        );
4577        assert_eq!(
4578            preview.status,
4579            200,
4580            "body: {}",
4581            String::from_utf8_lossy(&preview.body)
4582        );
4583        let preview_val = body_json(&preview);
4584        assert_eq!(preview_val["draft_id"], roster_id);
4585        assert_eq!(preview_val["mode"], "incremental");
4586        assert_eq!(preview_val["can_apply"], true);
4587        assert!(preview_val["changes"]
4588            .as_array()
4589            .map(|changes| !changes.is_empty())
4590            .unwrap_or(false));
4591
4592        let del = route_one(
4593            &request(
4594                "DELETE",
4595                &format!("/api/v1/rosters/drafts/{roster_id}"),
4596                b"",
4597            ),
4598            &root,
4599        );
4600        assert_eq!(del.status, 204);
4601        let del_again = route_one(
4602            &request(
4603                "DELETE",
4604                &format!("/api/v1/rosters/drafts/{roster_id}"),
4605                b"",
4606            ),
4607            &root,
4608        );
4609        assert_eq!(del_again.status, 404);
4610    }
4611
4612    #[test]
4613    fn roster_upload_rejects_missing_file_field() {
4614        let root = test_web_root();
4615        // A multipart body with no `file` part at all.
4616        let body = b"--b\r\nContent-Disposition: form-data; name=\"other\"\r\n\r\nx\r\n--b--\r\n";
4617        let response = route_one(
4618            &request_with_content_type(
4619                "POST",
4620                "/api/v1/rosters/drafts",
4621                body,
4622                Some("multipart/form-data; boundary=b"),
4623            ),
4624            &root,
4625        );
4626        assert_eq!(response.status, 422);
4627        assert!(body_json(&response)["error"]
4628            .as_str()
4629            .unwrap()
4630            .contains("file"));
4631    }
4632
4633    #[test]
4634    fn roster_upload_rejects_invalid_csv() {
4635        let root = test_web_root();
4636        let body = multipart_body(b"", "roster.csv", "bnd");
4637        let response = route_one(
4638            &request_with_content_type(
4639                "POST",
4640                "/api/v1/rosters/drafts",
4641                &body,
4642                Some("multipart/form-data; boundary=bnd"),
4643            ),
4644            &root,
4645        );
4646        assert_eq!(response.status, 422);
4647    }
4648
4649    #[test]
4650    fn roster_upload_requires_multipart_content_type() {
4651        let root = test_web_root();
4652        let response = route_one(
4653            &request("POST", "/api/v1/rosters/drafts", b"some csv"),
4654            &root,
4655        );
4656        assert_eq!(response.status, 400);
4657    }
4658
4659    #[test]
4660    fn roster_get_missing_is_404() {
4661        let root = test_web_root();
4662        let response = route_one(
4663            &request("GET", "/api/v1/rosters/drafts/does-not-exist", b""),
4664            &root,
4665        );
4666        assert_eq!(response.status, 404);
4667        assert!(body_json(&response).get("error").is_some());
4668    }
4669
4670    #[test]
4671    fn multipart_parser_handles_realistic_browser_boundary() {
4672        let boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW";
4673        let csv = b"name\nAlice\nBob\n";
4674        let mut body = Vec::new();
4675        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
4676        body.extend_from_slice(
4677            b"Content-Disposition: form-data; name=\"file\"; filename=\"roster.csv\"\r\n",
4678        );
4679        body.extend_from_slice(b"Content-Type: text/csv\r\n\r\n");
4680        body.extend_from_slice(csv);
4681        body.extend_from_slice(b"\r\n");
4682        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
4683        body.extend_from_slice(b"Content-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n");
4684        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
4685
4686        let fields = parse_multipart(&body, boundary).unwrap();
4687        assert_eq!(fields.get("file").map(Vec::as_slice), Some(csv.as_slice()));
4688        assert_eq!(
4689            fields.get("note").map(Vec::as_slice),
4690            Some(b"hello".as_slice())
4691        );
4692    }
4693
4694    #[test]
4695    fn multipart_parser_handles_filename_before_name() {
4696        let boundary = "b";
4697        let body = format!(
4698            "--{boundary}\r\nContent-Disposition: form-data; filename=\"x.csv\"; name=\"file\"\r\n\r\nabc\r\n--{boundary}--\r\n"
4699        );
4700        let fields = parse_multipart(body.as_bytes(), boundary).unwrap();
4701        assert_eq!(
4702            fields.get("file").map(Vec::as_slice),
4703            Some(b"abc".as_slice())
4704        );
4705    }
4706
4707    #[test]
4708    fn multipart_boundary_extraction_is_robust() {
4709        assert_eq!(
4710            multipart_boundary("multipart/form-data; boundary=abc"),
4711            Some("abc".to_string())
4712        );
4713        assert_eq!(
4714            multipart_boundary("multipart/form-data; boundary=\"abc\""),
4715            Some("abc".to_string())
4716        );
4717        assert_eq!(
4718            multipart_boundary("multipart/form-data; charset=utf-8; boundary=--xyz"),
4719            Some("--xyz".to_string())
4720        );
4721        assert_eq!(multipart_boundary("application/json"), None);
4722        assert_eq!(multipart_boundary("multipart/form-data"), None);
4723    }
4724
4725    #[test]
4726    fn full_teacher_flow_upload_generate_edit_export() {
4727        let root = test_web_root();
4728        let editor_store = editing::new_draft_store();
4729        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
4730
4731        // 1. Upload a roster.
4732        let csv = b"student_id,name,gender,height_cm,score,vision\nS1,Alice,F,160,90,0.8\nS2,Bob,M,165,81,0.6\nS3,Carol,F,150,75,1.0\nS4,Dave,M,175,88,0.9\nS5,Eve,F,158,90,0.7\n";
4733        let boundary = "----WebKitFormBoundaryFlowBoundary";
4734        let upload_body = multipart_body(csv, "roster.csv", boundary);
4735        let upload = route(
4736            &request_with_content_type(
4737                "POST",
4738                "/api/v1/rosters/drafts",
4739                &upload_body,
4740                Some(&format!("multipart/form-data; boundary={boundary}")),
4741            ),
4742            &root,
4743            &editor_store,
4744            &solve_requests,
4745            &root,
4746        );
4747        assert_eq!(upload.status, 200);
4748        let roster = body_json(&upload);
4749        assert_eq!(roster["row_count"], 5);
4750        let roster_id = roster["draft_id"].as_str().unwrap().to_string();
4751
4752        // 2. Preview an incremental update.
4753        let preview_body = json!({
4754            "mapping": roster["suggested_mapping"],
4755            "mode": "incremental",
4756            "current_students": [],
4757            "current_revision": 0,
4758            "updated_fields": ["name"]
4759        });
4760        let preview = route(
4761            &request(
4762                "POST",
4763                &format!("/api/v1/rosters/drafts/{roster_id}/preview"),
4764                &serde_json::to_vec(&preview_body).unwrap(),
4765            ),
4766            &root,
4767            &editor_store,
4768            &solve_requests,
4769            &root,
4770        );
4771        assert_eq!(
4772            preview.status,
4773            200,
4774            "body: {}",
4775            String::from_utf8_lossy(&preview.body)
4776        );
4777        let preview_val = body_json(&preview);
4778        assert_eq!(preview_val["draft_id"], roster_id);
4779        assert_eq!(preview_val["can_apply"], true);
4780
4781        // 3. Generate a plan for the five uploaded students.
4782        let problem = json!({
4783            "api_version": 2,
4784            "student_count": 5,
4785            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0],[4.0,1.0],[5.0,1.0],[6.0,1.0],[7.0,1.0],[8.0,1.0],[9.0,1.0]],
4786            "students": [
4787                {"key": "S1", "display_name": "Alice", "score": 93.0},
4788                {"key": "S2", "display_name": "Bob", "score": 81.0},
4789                {"key": "S3", "display_name": "Carol", "score": 75.0},
4790                {"key": "S4", "display_name": "Dave", "score": 88.0},
4791                {"key": "S5", "display_name": "Eve", "score": 90.0}
4792            ]
4793        });
4794        let gen = route(
4795            &request(
4796                "POST",
4797                "/api/v1/classes/generate",
4798                &serde_json::to_vec(&problem).unwrap(),
4799            ),
4800            &root,
4801            &editor_store,
4802            &solve_requests,
4803            &root,
4804        );
4805        assert_eq!(
4806            gen.status,
4807            200,
4808            "body: {}",
4809            String::from_utf8_lossy(&gen.body)
4810        );
4811        let gen_val = body_json(&gen);
4812        let draft_id = gen_val["editor"]["draft_id"].as_str().unwrap().to_string();
4813        assert_eq!(gen_val["recommended_candidate_id"], draft_id);
4814        assert_eq!(gen_val["candidates"][0]["recommended"], true);
4815        assert_eq!(gen_val["class_name"], "Classroom");
4816
4817        // 4. Fetch the editor state.
4818        let fetch = route(
4819            &request("GET", &format!("/api/v1/editing/drafts/{draft_id}"), b""),
4820            &root,
4821            &editor_store,
4822            &solve_requests,
4823            &root,
4824        );
4825        assert_eq!(fetch.status, 200);
4826        let before = body_json(&fetch);
4827        assert_eq!(before["revision"], 0);
4828        assert_eq!(before["students"].as_array().map(Vec::len), Some(5));
4829        let s1_before = before["students"]
4830            .as_array()
4831            .unwrap()
4832            .iter()
4833            .find(|student| student["student_key"] == "S1")
4834            .unwrap()["seat_id"]
4835            .clone();
4836        let s2_before = before["students"]
4837            .as_array()
4838            .unwrap()
4839            .iter()
4840            .find(|student| student["student_key"] == "S2")
4841            .unwrap()["seat_id"]
4842            .clone();
4843
4844        // 5. Swap two students.
4845        let command = json!({
4846            "kind": "seattrellis_editor_command",
4847            "protocol_version": "1.0",
4848            "command_id": "cmd-flow-1",
4849            "draft_id": draft_id,
4850            "base_revision": 0,
4851            "action": "apply",
4852            "operations": [
4853                {"kind": "swap_students", "payload": {"first_student": "S1", "second_student": "S2"}}
4854            ]
4855        });
4856        let swapped = route(
4857            &request(
4858                "POST",
4859                &format!("/api/v1/editing/drafts/{draft_id}/commands"),
4860                &serde_json::to_vec(&command).unwrap(),
4861            ),
4862            &root,
4863            &editor_store,
4864            &solve_requests,
4865            &root,
4866        );
4867        assert_eq!(
4868            swapped.status,
4869            200,
4870            "body: {}",
4871            String::from_utf8_lossy(&swapped.body)
4872        );
4873        let swapped_val = body_json(&swapped);
4874        assert_eq!(swapped_val["revision"], 1);
4875        let s1_after = swapped_val["students"]
4876            .as_array()
4877            .unwrap()
4878            .iter()
4879            .find(|student| student["student_key"] == "S1")
4880            .unwrap()["seat_id"]
4881            .clone();
4882        let s2_after = swapped_val["students"]
4883            .as_array()
4884            .unwrap()
4885            .iter()
4886            .find(|student| student["student_key"] == "S2")
4887            .unwrap()["seat_id"]
4888            .clone();
4889        assert_eq!(s1_after, s2_before);
4890        assert_eq!(s2_after, s1_before);
4891
4892        // 6. Export the edited plan as SVG.
4893        let export_body = json!({
4894            "draft_id": draft_id,
4895            "format": "svg",
4896            "template": "teacher",
4897            "privacy": {"hide_scores": false, "hide_notes": false, "hide_special_needs": false, "anonymize": false, "show_height": false, "show_vision": false},
4898            "orientation": "portrait",
4899            "page_scale": 1.0,
4900            "locale": "zh",
4901            "show_student_ids": true
4902        });
4903        let export = route(
4904            &request(
4905                "POST",
4906                "/api/v1/exports",
4907                &serde_json::to_vec(&export_body).unwrap(),
4908            ),
4909            &root,
4910            &editor_store,
4911            &solve_requests,
4912            &root,
4913        );
4914        assert_eq!(
4915            export.status,
4916            200,
4917            "body: {}",
4918            String::from_utf8_lossy(&export.body)
4919        );
4920        assert_eq!(export.content_type, Some("image/svg+xml"));
4921        assert!(export
4922            .content_disposition
4923            .as_deref()
4924            .unwrap()
4925            .contains("filename=\"seat-plan.svg\""));
4926        assert!(export.body.starts_with(b"<svg"));
4927
4928        // 7. Delete the roster draft (204, then 404).
4929        let del = route(
4930            &request(
4931                "DELETE",
4932                &format!("/api/v1/rosters/drafts/{roster_id}"),
4933                b"",
4934            ),
4935            &root,
4936            &editor_store,
4937            &solve_requests,
4938            &root,
4939        );
4940        assert_eq!(del.status, 204);
4941        let del_again = route(
4942            &request(
4943                "DELETE",
4944                &format!("/api/v1/rosters/drafts/{roster_id}"),
4945                b"",
4946            ),
4947            &root,
4948            &editor_store,
4949            &solve_requests,
4950            &root,
4951        );
4952        assert_eq!(del_again.status, 404);
4953    }
4954
4955    #[test]
4956    fn export_print_html_renders_dedicated_layout() {
4957        let root = test_web_root();
4958        let editor_store = editing::new_draft_store();
4959        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
4960        let problem = json!({
4961            "api_version": 2,
4962            "student_count": 2,
4963            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0]],
4964            "students": [{"key": "S1"}, {"key": "S2"}]
4965        });
4966        let gen = route(
4967            &request(
4968                "POST",
4969                "/api/v1/classes/generate",
4970                &serde_json::to_vec(&problem).unwrap(),
4971            ),
4972            &root,
4973            &editor_store,
4974            &solve_requests,
4975            &root,
4976        );
4977        assert_eq!(gen.status, 200);
4978        let draft_id = body_json(&gen)["editor"]["draft_id"]
4979            .as_str()
4980            .unwrap()
4981            .to_string();
4982
4983        let export_body = json!({
4984            "draft_id": draft_id,
4985            "format": "print-html",
4986            "template": "public",
4987            "privacy": {"hide_scores": false, "hide_notes": false, "hide_special_needs": false, "anonymize": false, "show_height": false, "show_vision": false},
4988            "orientation": "landscape",
4989            "page_scale": 1.0,
4990            "locale": "en",
4991            "show_student_ids": false
4992        });
4993        let export = route(
4994            &request(
4995                "POST",
4996                "/api/v1/exports",
4997                &serde_json::to_vec(&export_body).unwrap(),
4998            ),
4999            &root,
5000            &editor_store,
5001            &solve_requests,
5002            &root,
5003        );
5004        assert_eq!(
5005            export.status,
5006            200,
5007            "body: {}",
5008            String::from_utf8_lossy(&export.body)
5009        );
5010        assert_eq!(export.content_type, Some("text/html; charset=utf-8"));
5011        let body = String::from_utf8_lossy(&export.body);
5012        // Dedicated print layout (print-layout-spec): landscape @page,
5013        // platform annotation, and the reproducibility seed line.
5014        assert!(
5015            body.contains("@page { size: 297mm 210mm"),
5016            "landscape A4 default"
5017        );
5018        assert!(body.contains("讲台 ↑"), "platform annotation");
5019        assert!(body.contains("seed "), "reproducibility line");
5020    }
5021
5022    #[test]
5023    fn export_unknown_draft_is_404() {
5024        let root = test_web_root();
5025        let editor_store = editing::new_draft_store();
5026        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
5027        let export_body = json!({
5028            "draft_id": "draft-missing",
5029            "format": "svg",
5030            "template": "teacher",
5031            "privacy": {},
5032            "orientation": "portrait",
5033            "page_scale": 1.0,
5034            "locale": "zh",
5035            "show_student_ids": true
5036        });
5037        let export = route(
5038            &request(
5039                "POST",
5040                "/api/v1/exports",
5041                &serde_json::to_vec(&export_body).unwrap(),
5042            ),
5043            &root,
5044            &editor_store,
5045            &solve_requests,
5046            &root,
5047        );
5048        assert_eq!(export.status, 404);
5049    }
5050
5051    #[test]
5052    fn export_rejects_an_edit_that_breaks_the_original_hard_rules() {
5053        let root = test_web_root();
5054        let editor_store = editing::new_draft_store();
5055        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
5056        let problem = json!({
5057            "api_version": 2,
5058            "student_count": 2,
5059            "seat_positions": [[0.0, 0.0], [1.0, 0.0]],
5060            "fixed_seats": [[0, 0]],
5061            "students": [{"key": "A"}, {"key": "B"}]
5062        });
5063        let generated = route(
5064            &request(
5065                "POST",
5066                "/api/v1/classes/generate",
5067                &serde_json::to_vec(&problem).unwrap(),
5068            ),
5069            &root,
5070            &editor_store,
5071            &solve_requests,
5072            &root,
5073        );
5074        assert_eq!(generated.status, 200);
5075        let draft_id = body_json(&generated)["editor"]["draft_id"]
5076            .as_str()
5077            .unwrap()
5078            .to_string();
5079
5080        // The editing protocol maintains uniqueness but intentionally does not
5081        // own solver hard-rule semantics. Export must therefore revalidate.
5082        let swap = json!({
5083            "kind": "seattrellis_editor_command",
5084            "protocol_version": "1.0",
5085            "command_id": "break-fixed-seat",
5086            "draft_id": draft_id,
5087            "base_revision": 0,
5088            "action": "apply",
5089            "operations": [{
5090                "kind": "swap_students",
5091                "payload": {"first_student": "A", "second_student": "B"}
5092            }]
5093        });
5094        let edited = route(
5095            &request(
5096                "POST",
5097                &format!("/api/v1/editing/drafts/{draft_id}/commands"),
5098                &serde_json::to_vec(&swap).unwrap(),
5099            ),
5100            &root,
5101            &editor_store,
5102            &solve_requests,
5103            &root,
5104        );
5105        assert_eq!(edited.status, 200);
5106
5107        let export_body = json!({
5108            "draft_id": draft_id,
5109            "format": "svg",
5110            "template": "teacher",
5111            "privacy": {},
5112            "orientation": "portrait",
5113            "page_scale": 1.0,
5114            "locale": "zh",
5115            "show_student_ids": true
5116        });
5117        let export = route(
5118            &request(
5119                "POST",
5120                "/api/v1/exports",
5121                &serde_json::to_vec(&export_body).unwrap(),
5122            ),
5123            &root,
5124            &editor_store,
5125            &solve_requests,
5126            &root,
5127        );
5128        assert_eq!(export.status, 422);
5129        assert_eq!(body_json(&export)["error"], "invalid_export_assignment");
5130    }
5131
5132    #[test]
5133    fn editing_command_validation_errors_map_to_4xx() {
5134        let root = test_web_root();
5135        let editor_store = editing::new_draft_store();
5136        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
5137
5138        // Unknown draft -> 404.
5139        let command = json!({
5140            "kind": "seattrellis_editor_command",
5141            "protocol_version": "1.0",
5142            "command_id": "cmd-x",
5143            "draft_id": "missing",
5144            "base_revision": 0,
5145            "action": "apply",
5146            "operations": [{"kind": "swap_students", "payload": {"first_student": "A", "second_student": "B"}}]
5147        });
5148        let response = route(
5149            &request(
5150                "POST",
5151                "/api/v1/editing/drafts/missing/commands",
5152                &serde_json::to_vec(&command).unwrap(),
5153            ),
5154            &root,
5155            &editor_store,
5156            &solve_requests,
5157            &root,
5158        );
5159        assert_eq!(response.status, 404);
5160
5161        // Stale base revision -> 409 after one applied command.
5162        let problem = json!({
5163            "api_version": 2,
5164            "student_count": 2,
5165            "seat_positions": [[1.0,1.0],[2.0,1.0],[3.0,1.0]],
5166            "students": [{"key": "A"}, {"key": "B"}]
5167        });
5168        let gen = route(
5169            &request(
5170                "POST",
5171                "/api/v1/classes/generate",
5172                &serde_json::to_vec(&problem).unwrap(),
5173            ),
5174            &root,
5175            &editor_store,
5176            &solve_requests,
5177            &root,
5178        );
5179        assert_eq!(gen.status, 200);
5180        let draft_id = body_json(&gen)["editor"]["draft_id"]
5181            .as_str()
5182            .unwrap()
5183            .to_string();
5184
5185        let first = json!({
5186            "kind": "seattrellis_editor_command",
5187            "protocol_version": "1.0",
5188            "command_id": "cmd-1",
5189            "draft_id": draft_id,
5190            "base_revision": 0,
5191            "action": "apply",
5192            "operations": [{"kind": "swap_students", "payload": {"first_student": "A", "second_student": "B"}}]
5193        });
5194        let ok = route(
5195            &request(
5196                "POST",
5197                &format!("/api/v1/editing/drafts/{draft_id}/commands"),
5198                &serde_json::to_vec(&first).unwrap(),
5199            ),
5200            &root,
5201            &editor_store,
5202            &solve_requests,
5203            &root,
5204        );
5205        assert_eq!(ok.status, 200);
5206        assert_eq!(body_json(&ok)["revision"], 1);
5207
5208        // Same base_revision again (fresh command id) is now stale -> 409.
5209        let stale_body = json!({
5210            "kind": "seattrellis_editor_command",
5211            "protocol_version": "1.0",
5212            "command_id": "cmd-2",
5213            "draft_id": draft_id,
5214            "base_revision": 0,
5215            "action": "apply",
5216            "operations": [{"kind": "swap_students", "payload": {"first_student": "A", "second_student": "B"}}]
5217        });
5218        let stale = route(
5219            &request(
5220                "POST",
5221                &format!("/api/v1/editing/drafts/{draft_id}/commands"),
5222                &serde_json::to_vec(&stale_body).unwrap(),
5223            ),
5224            &root,
5225            &editor_store,
5226            &solve_requests,
5227            &root,
5228        );
5229        assert_eq!(stale.status, 409);
5230        assert!(body_json(&stale)["error"]
5231            .as_str()
5232            .unwrap()
5233            .contains("stale"));
5234
5235        // Malformed JSON body -> 400.
5236        let bad = route(
5237            &request(
5238                "POST",
5239                &format!("/api/v1/editing/drafts/{draft_id}/commands"),
5240                b"not json",
5241            ),
5242            &root,
5243            &editor_store,
5244            &solve_requests,
5245            &root,
5246        );
5247        assert_eq!(bad.status, 400);
5248    }
5249
5250    /// Contract test (L): the state documents the server actually emits —
5251    /// fetched state, command responses (with the injected `validation`
5252    /// object), and rotation-load rebuilt drafts — must all validate against
5253    /// the published `schemas/editor-state.schema.json`.
5254    #[test]
5255    fn editor_state_responses_validate_against_published_schema() {
5256        let schema_path =
5257            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas/editor-state.schema.json");
5258        let schema: Value = serde_json::from_slice(
5259            &fs::read(&schema_path)
5260                .unwrap_or_else(|error| panic!("cannot read {}: {error}", schema_path.display())),
5261        )
5262        .unwrap();
5263        let validator = jsonschema::validator_for(&schema)
5264            .unwrap_or_else(|error| panic!("published editor-state schema is invalid: {error}"));
5265        let assert_valid = |label: &str, document: &Value| {
5266            let errors: Vec<String> = validator
5267                .iter_errors(document)
5268                .map(|error| error.to_string())
5269                .collect();
5270            assert!(
5271                errors.is_empty(),
5272                "{label} violates the published editor-state schema: {errors:?}"
5273            );
5274        };
5275
5276        let root = test_web_root();
5277        let editor_store = editing::new_draft_store();
5278        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
5279
5280        // A real generate flow creates the draft.
5281        let problem = json!({
5282            "api_version": 2,
5283            "student_count": 2,
5284            "seat_positions": [[1.0, 1.0], [2.0, 1.0], [3.0, 1.0]],
5285            "students": [{"key": "A"}, {"key": "B"}]
5286        });
5287        let generated = route(
5288            &request(
5289                "POST",
5290                "/api/v1/classes/generate",
5291                &serde_json::to_vec(&problem).unwrap(),
5292            ),
5293            &root,
5294            &editor_store,
5295            &solve_requests,
5296            &root,
5297        );
5298        assert_eq!(generated.status, 200);
5299        let draft_id = body_json(&generated)["editor"]["draft_id"]
5300            .as_str()
5301            .unwrap()
5302            .to_string();
5303
5304        // 1. Fetched state validates and carries no `validation` object.
5305        let fetched = route(
5306            &request("GET", &format!("/api/v1/editing/drafts/{draft_id}"), b""),
5307            &root,
5308            &editor_store,
5309            &solve_requests,
5310            &root,
5311        );
5312        assert_eq!(fetched.status, 200);
5313        let fetched_value = body_json(&fetched);
5314        assert!(fetched_value.get("validation").is_none());
5315        assert_valid("fetched editor state", &fetched_value);
5316
5317        // 2. Command responses validate with the extra `validation` object.
5318        let command = json!({
5319            "kind": "seattrellis_editor_command",
5320            "protocol_version": "1.0",
5321            "command_id": "contract-cmd-1",
5322            "draft_id": draft_id,
5323            "base_revision": 0,
5324            "action": "apply",
5325            "operations": [{
5326                "kind": "swap_students",
5327                "payload": {"first_student": "A", "second_student": "B"}
5328            }]
5329        });
5330        let applied = route(
5331            &request(
5332                "POST",
5333                &format!("/api/v1/editing/drafts/{draft_id}/commands"),
5334                &serde_json::to_vec(&command).unwrap(),
5335            ),
5336            &root,
5337            &editor_store,
5338            &solve_requests,
5339            &root,
5340        );
5341        assert_eq!(applied.status, 200);
5342        let applied_value = body_json(&applied);
5343        let validation = applied_value
5344            .get("validation")
5345            .expect("command responses carry the validation contract");
5346        assert_eq!(validation["valid"], true);
5347        assert_eq!(validation["hard_constraints_satisfied"], true);
5348        assert!(validation["violations"].is_array());
5349        assert_valid("command response state", &applied_value);
5350
5351        // 3. Rotation-load rebuilt drafts use the same serializer and must
5352        //    validate too.
5353        let dir = rotation_project_dir();
5354        let project_path = rotation_project_file(&dir);
5355        let save = route_with_store(
5356            &request(
5357                "POST",
5358                "/api/v1/projects/rotation/save",
5359                &serde_json::to_vec(&json!({
5360                    "project_path": project_path,
5361                    "rotation_plan": rotation_plan_value(),
5362                }))
5363                .unwrap(),
5364            ),
5365            &root,
5366            &editor_store,
5367            &solve_requests,
5368        );
5369        assert_eq!(save.status, 200);
5370        let save_val = body_json(&save);
5371        let output_path = save_val["output_path"].as_str().unwrap();
5372        let loaded = route_with_store(
5373            &request(
5374                "POST",
5375                "/api/v1/projects/rotation/load",
5376                &serde_json::to_vec(&json!({
5377                    "project_path": project_path,
5378                    "artifact_path": output_path,
5379                }))
5380                .unwrap(),
5381            ),
5382            &root,
5383            &editor_store,
5384            &solve_requests,
5385        );
5386        assert_eq!(
5387            loaded.status,
5388            200,
5389            "body: {}",
5390            String::from_utf8_lossy(&loaded.body)
5391        );
5392        for editor in body_json(&loaded)["period_editors"].as_array().unwrap() {
5393            assert!(editor.get("validation").is_none());
5394            assert_valid("rebuilt rotation draft", editor);
5395        }
5396    }
5397
5398    #[test]
5399    fn method_not_allowed_on_api() {
5400        let root = test_web_root();
5401        let response = route_one(&request("PUT", "/api/v1/health", b""), &root);
5402        assert_eq!(response.status, 405);
5403    }
5404
5405    #[test]
5406    fn unknown_api_route_is_404_json() {
5407        let root = test_web_root();
5408        let response = route_one(&request("GET", "/api/v1/nope", b""), &root);
5409        assert_eq!(response.status, 404);
5410        assert!(body_json(&response).get("error").is_some());
5411    }
5412
5413    #[test]
5414    fn percent_decode_roundtrips() {
5415        assert_eq!(percent_decode("abc").unwrap(), "abc");
5416        assert_eq!(percent_decode("%2e%2E/x").unwrap(), "../x");
5417        assert_eq!(percent_decode("%20").unwrap(), " ");
5418        assert!(percent_decode("%2").is_err());
5419        assert!(percent_decode("%zz").is_err());
5420        assert!(percent_decode("%00").unwrap().contains('\0'));
5421    }
5422
5423    #[test]
5424    fn safe_join_blocks_escapes() {
5425        let root = test_web_root();
5426        assert!(safe_join(&root, "/assets/app.js").is_some());
5427        assert!(safe_join(&root, "/").is_some());
5428        assert!(safe_join(&root, "/..").is_none());
5429        assert!(safe_join(&root, "/assets/../../secret").is_none());
5430        assert!(safe_join(&root, "/%2e%2e/secret").is_none());
5431    }
5432
5433    // --- Layout route integration tests ------------------------------------
5434
5435    /// Layout routes: create a draft, fetch it, dispatch a command, compile it,
5436    /// and delete it. Covers the full draft lifecycle against the real JSON
5437    /// contract (`LayoutStateResponse` / `LayoutCommand` / `CompiledLayoutResponse`).
5438    #[test]
5439    fn layout_draft_lifecycle_create_get_command_compiled_delete() {
5440        let root = test_web_root();
5441
5442        // 1. Create a 3x4 rectangular draft.
5443        let create_body = json!({ "name": "Layout Test", "rows": 3, "columns": 4 });
5444        let create = route_one(
5445            &request(
5446                "POST",
5447                "/api/v1/layouts/drafts",
5448                &serde_json::to_vec(&create_body).unwrap(),
5449            ),
5450            &root,
5451        );
5452        assert_eq!(
5453            create.status,
5454            200,
5455            "body: {}",
5456            String::from_utf8_lossy(&create.body)
5457        );
5458        let state = body_json(&create);
5459        assert_eq!(state["kind"], "seattrellis_layout_state");
5460        assert_eq!(state["api_version"], "1");
5461        assert_eq!(state["name"], "Layout Test");
5462        assert_eq!(state["rows"], 3);
5463        assert_eq!(state["columns"], 4);
5464        assert_eq!(state["revision"], 0);
5465        assert_eq!(state["usable_seat_count"], 12);
5466        let draft_id = state["draft_id"].as_str().unwrap().to_string();
5467
5468        // 2. Fetch the state through the GET route.
5469        let get = route_one(
5470            &request("GET", &format!("/api/v1/layouts/drafts/{draft_id}"), b""),
5471            &root,
5472        );
5473        assert_eq!(get.status, 200);
5474        assert_eq!(body_json(&get)["draft_id"], draft_id);
5475
5476        // 3. Dispatch a command that converts a seat into an aisle.
5477        let command = json!({
5478            "command_id": "cmd-layout-1",
5479            "draft_id": draft_id,
5480            "base_revision": 0,
5481            "action": "apply",
5482            "operation": {"kind": "set_cell", "payload": {"row": 1, "column": 1, "kind": "aisle"}}
5483        });
5484        let applied = route_one(
5485            &request(
5486                "POST",
5487                &format!("/api/v1/layouts/drafts/{draft_id}/commands"),
5488                &serde_json::to_vec(&command).unwrap(),
5489            ),
5490            &root,
5491        );
5492        assert_eq!(
5493            applied.status,
5494            200,
5495            "body: {}",
5496            String::from_utf8_lossy(&applied.body)
5497        );
5498        let after = body_json(&applied);
5499        assert_eq!(after["revision"], 1);
5500        assert_eq!(after["usable_seat_count"], 11);
5501
5502        // 4. Compile the edited draft into the strict solver layout.
5503        let compiled = route_one(
5504            &request(
5505                "GET",
5506                &format!("/api/v1/layouts/drafts/{draft_id}/compiled"),
5507                b"",
5508            ),
5509            &root,
5510        );
5511        assert_eq!(
5512            compiled.status,
5513            200,
5514            "body: {}",
5515            String::from_utf8_lossy(&compiled.body)
5516        );
5517        let compiled_val = body_json(&compiled);
5518        assert_eq!(compiled_val["api_version"], "1");
5519        assert_eq!(compiled_val["draft_id"], draft_id);
5520        // 11 seats + the 1 aisle cell = 12 layout nodes.
5521        assert_eq!(
5522            compiled_val["layout"]["seats"].as_array().map(Vec::len),
5523            Some(12)
5524        );
5525
5526        // 5. Delete the draft (204, then 404).
5527        let del = route_one(
5528            &request("DELETE", &format!("/api/v1/layouts/drafts/{draft_id}"), b""),
5529            &root,
5530        );
5531        assert_eq!(del.status, 204);
5532        let del_again = route_one(
5533            &request("DELETE", &format!("/api/v1/layouts/drafts/{draft_id}"), b""),
5534            &root,
5535        );
5536        assert_eq!(del_again.status, 404);
5537    }
5538
5539    /// Layout command error mapping: unknown draft -> 404, malformed body -> 400,
5540    /// stale base revision -> 409.
5541    #[test]
5542    fn layout_command_errors_map_to_4xx() {
5543        let root = test_web_root();
5544
5545        // Unknown draft -> 404.
5546        let command = json!({
5547            "command_id": "cmd-missing",
5548            "draft_id": "missing",
5549            "base_revision": 0,
5550            "action": "apply",
5551            "operation": {"kind": "set_cell", "payload": {"row": 1, "column": 1, "kind": "seat"}}
5552        });
5553        let response = route_one(
5554            &request(
5555                "POST",
5556                "/api/v1/layouts/drafts/missing/commands",
5557                &serde_json::to_vec(&command).unwrap(),
5558            ),
5559            &root,
5560        );
5561        assert_eq!(response.status, 404);
5562
5563        // Malformed JSON body -> 400.
5564        let bad = route_one(
5565            &request("POST", "/api/v1/layouts/drafts/x/commands", b"not json"),
5566            &root,
5567        );
5568        assert_eq!(bad.status, 400);
5569
5570        // Stale base revision -> 409 after one applied command.
5571        let create_body = json!({ "name": "Stale", "rows": 2, "columns": 2 });
5572        let create = route_one(
5573            &request(
5574                "POST",
5575                "/api/v1/layouts/drafts",
5576                &serde_json::to_vec(&create_body).unwrap(),
5577            ),
5578            &root,
5579        );
5580        assert_eq!(create.status, 200);
5581        let draft_id = body_json(&create)["draft_id"].as_str().unwrap().to_string();
5582        let first = json!({
5583            "command_id": "cmd-1",
5584            "draft_id": draft_id,
5585            "base_revision": 0,
5586            "action": "apply",
5587            "operation": {"kind": "set_cell", "payload": {"row": 1, "column": 1, "kind": "aisle"}}
5588        });
5589        let ok = route_one(
5590            &request(
5591                "POST",
5592                &format!("/api/v1/layouts/drafts/{draft_id}/commands"),
5593                &serde_json::to_vec(&first).unwrap(),
5594            ),
5595            &root,
5596        );
5597        assert_eq!(ok.status, 200);
5598        let stale = json!({
5599            "command_id": "cmd-2",
5600            "draft_id": draft_id,
5601            "base_revision": 0,
5602            "action": "apply",
5603            "operation": {"kind": "set_cell", "payload": {"row": 1, "column": 1, "kind": "aisle"}}
5604        });
5605        let conflict = route_one(
5606            &request(
5607                "POST",
5608                &format!("/api/v1/layouts/drafts/{draft_id}/commands"),
5609                &serde_json::to_vec(&stale).unwrap(),
5610            ),
5611            &root,
5612        );
5613        assert_eq!(conflict.status, 409);
5614    }
5615
5616    /// Layout create validation: multiple sources or an unknown template are 422.
5617    #[test]
5618    fn layout_create_validation_error_is_422() {
5619        let root = test_web_root();
5620        let multiple_sources = json!({ "template_id": "standard-30", "rows": 5, "columns": 6 });
5621        let response = route_one(
5622            &request(
5623                "POST",
5624                "/api/v1/layouts/drafts",
5625                &serde_json::to_vec(&multiple_sources).unwrap(),
5626            ),
5627            &root,
5628        );
5629        assert_eq!(response.status, 422);
5630
5631        let unknown_template = json!({ "template_id": "standard-999" });
5632        let response = route_one(
5633            &request(
5634                "POST",
5635                "/api/v1/layouts/drafts",
5636                &serde_json::to_vec(&unknown_template).unwrap(),
5637            ),
5638            &root,
5639        );
5640        assert_eq!(response.status, 422);
5641    }
5642
5643    // --- Project route integration tests ------------------------------------
5644
5645    /// Copy the repo's example project (plus every referenced file) into a fresh
5646    /// temporary directory, returning `(dir, copied project path)`.
5647    fn example_project_copy(tag: &str) -> (PathBuf, PathBuf) {
5648        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
5649        let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
5650        let dir = std::env::temp_dir().join(format!(
5651            "seattrellis_projects_test_{}_{}_{}",
5652            std::process::id(),
5653            tag,
5654            seq
5655        ));
5656        let _ = fs::remove_dir_all(&dir);
5657        fs::create_dir_all(&dir).unwrap();
5658        for name in [
5659            "project.seattrellis.json",
5660            "students.csv",
5661            "classroom.json",
5662            "rules_multi_candidate.json",
5663        ] {
5664            fs::copy(examples.join(name), dir.join(name)).unwrap();
5665        }
5666        for name in ["history", "outputs"] {
5667            copy_dir(&examples.join(name), &dir.join(name));
5668        }
5669        let project_path = dir.join("project.seattrellis.json");
5670        (dir, project_path)
5671    }
5672
5673    fn copy_dir(source: &Path, dest: &Path) {
5674        if !source.is_dir() {
5675            return;
5676        }
5677        fs::create_dir_all(dest).unwrap();
5678        for entry in fs::read_dir(source).unwrap().flatten() {
5679            let target = dest.join(entry.file_name());
5680            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5681                copy_dir(&entry.path(), &target);
5682            } else {
5683                fs::copy(entry.path(), target).unwrap();
5684            }
5685        }
5686    }
5687
5688    /// Build a multipart body from named fields (name -> raw bytes).
5689    fn multipart_form(fields: &[(&str, &[u8])], boundary: &str) -> Vec<u8> {
5690        let mut body = Vec::new();
5691        for (name, value) in fields {
5692            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
5693            body.extend_from_slice(
5694                format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
5695            );
5696            body.extend_from_slice(value);
5697            body.extend_from_slice(b"\r\n");
5698        }
5699        body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
5700        body
5701    }
5702
5703    /// Project routes: list recent projects under a root, read history and
5704    /// privacy for a real project, pack it into a zip, and restore the zip into
5705    /// a fresh destination. Covers the full workspace flow against example data.
5706    #[test]
5707    fn project_routes_list_history_privacy_pack_restore() {
5708        let root = test_web_root();
5709        let (dir, project_path) = example_project_copy("flow");
5710        // Canonicalize so the asserted path matches the `list_projects` output
5711        // (which canonicalizes; on macOS `/var` resolves to `/private/var`).
5712        let project_path_str = fs::canonicalize(&project_path)
5713            .unwrap()
5714            .to_string_lossy()
5715            .into_owned();
5716        let root_str = dir.to_string_lossy().into_owned();
5717
5718        // 1. List recent projects under the fixture directory.
5719        let list = route_one(
5720            &request(
5721                "GET",
5722                &format!("/api/v1/projects/recent?root={root_str}&limit=20"),
5723                b"",
5724            ),
5725            &root,
5726        );
5727        assert_eq!(
5728            list.status,
5729            200,
5730            "body: {}",
5731            String::from_utf8_lossy(&list.body)
5732        );
5733        let list_val = body_json(&list);
5734        assert_eq!(list_val["api_version"], "1");
5735        let projects = list_val["projects"].as_array().unwrap();
5736        assert!(
5737            projects
5738                .iter()
5739                .any(|project| project["path"] == project_path_str),
5740            "project list should include {project_path_str}: {projects:?}"
5741        );
5742
5743        // 2. Project history.
5744        let history_body = json!({ "project_path": project_path_str, "include_outputs": true });
5745        let history = route_one(
5746            &request(
5747                "POST",
5748                "/api/v1/projects/history",
5749                &serde_json::to_vec(&history_body).unwrap(),
5750            ),
5751            &root,
5752        );
5753        assert_eq!(
5754            history.status,
5755            200,
5756            "body: {}",
5757            String::from_utf8_lossy(&history.body)
5758        );
5759        let history_val = body_json(&history);
5760        assert_eq!(history_val["api_version"], "1");
5761        assert_eq!(history_val["project_name"], "Demo Class");
5762        assert!(history_val["history"].is_array());
5763        assert!(history_val["outputs"].is_array());
5764
5765        // 3. Project privacy scan.
5766        let privacy_body = json!({ "project_path": project_path_str, "include_outputs": true });
5767        let privacy = route_one(
5768            &request(
5769                "POST",
5770                "/api/v1/projects/privacy",
5771                &serde_json::to_vec(&privacy_body).unwrap(),
5772            ),
5773            &root,
5774        );
5775        assert_eq!(
5776            privacy.status,
5777            200,
5778            "body: {}",
5779            String::from_utf8_lossy(&privacy.body)
5780        );
5781        let privacy_val = body_json(&privacy);
5782        assert_eq!(privacy_val["api_version"], "1");
5783        assert!(
5784            privacy_val["files_scanned"].as_u64().unwrap_or(0) > 0,
5785            "privacy scan should read at least one file"
5786        );
5787
5788        // 4. Pack the project into a zip.
5789        let bundle_body = json!({ "project_path": project_path_str, "include_outputs": true });
5790        let bundle = route_one(
5791            &request(
5792                "POST",
5793                "/api/v1/projects/bundle",
5794                &serde_json::to_vec(&bundle_body).unwrap(),
5795            ),
5796            &root,
5797        );
5798        assert_eq!(
5799            bundle.status,
5800            200,
5801            "body: {}",
5802            String::from_utf8_lossy(&bundle.body)
5803        );
5804        assert_eq!(bundle.content_type, Some("application/zip"));
5805        assert!(bundle
5806            .content_disposition
5807            .as_deref()
5808            .unwrap()
5809            .contains("filename=\"project.seattrellis.zip\""));
5810        assert!(
5811            bundle.body.starts_with(b"PK"),
5812            "zip bytes should start with the PK magic"
5813        );
5814
5815        // 5. Restore the zip into a fresh destination directory.
5816        let output_dir = dir.join("restored");
5817        let output_dir_str = output_dir.to_string_lossy().into_owned();
5818        let boundary = "----SeatTrellisRestoreBoundary";
5819        let restore_body = multipart_form(
5820            &[
5821                ("bundle", bundle.body.as_slice()),
5822                ("output_dir", output_dir_str.as_bytes()),
5823                ("overwrite", b"false"),
5824            ],
5825            boundary,
5826        );
5827        let restore = route_one(
5828            &request_with_content_type(
5829                "POST",
5830                "/api/v1/projects/restore",
5831                &restore_body,
5832                Some(&format!("multipart/form-data; boundary={boundary}")),
5833            ),
5834            &root,
5835        );
5836        assert_eq!(
5837            restore.status,
5838            200,
5839            "body: {}",
5840            String::from_utf8_lossy(&restore.body)
5841        );
5842        let restore_val = body_json(&restore);
5843        assert_eq!(restore_val["api_version"], "1");
5844        let restored_path = restore_val["project_path"].as_str().unwrap();
5845        assert!(
5846            Path::new(restored_path).is_file(),
5847            "restored project file should exist: {restored_path}"
5848        );
5849    }
5850
5851    /// Project routes error mapping: a missing project file is 404, an
5852    /// existing-but-invalid project is 422, and a bad bundle upload is 422.
5853    #[test]
5854    fn project_routes_validation_errors() {
5855        let root = test_web_root();
5856
5857        // Missing project file -> 404.
5858        let history_body = json!({ "project_path": "/nonexistent/project.seattrellis.json" });
5859        let history = route_one(
5860            &request(
5861                "POST",
5862                "/api/v1/projects/history",
5863                &serde_json::to_vec(&history_body).unwrap(),
5864            ),
5865            &root,
5866        );
5867        assert_eq!(history.status, 404);
5868
5869        let bundle_body = json!({ "project_path": "/nonexistent/project.seattrellis.json" });
5870        let bundle = route_one(
5871            &request(
5872                "POST",
5873                "/api/v1/projects/bundle",
5874                &serde_json::to_vec(&bundle_body).unwrap(),
5875            ),
5876            &root,
5877        );
5878        assert_eq!(bundle.status, 404);
5879
5880        // An existing file that is not a project artifact -> 422.
5881        let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
5882        let dir = std::env::temp_dir().join(format!(
5883            "seattrellis_projects_invalid_test_{seq}_{}",
5884            std::process::id()
5885        ));
5886        let _ = fs::remove_dir_all(&dir);
5887        fs::create_dir_all(&dir).unwrap();
5888        let invalid = dir.join("broken.seattrellis.json");
5889        fs::write(&invalid, r#"{"not": "a project"}"#).unwrap();
5890        let invalid_str = invalid.to_string_lossy().into_owned();
5891        let history_body = json!({ "project_path": invalid_str });
5892        let history = route_one(
5893            &request(
5894                "POST",
5895                "/api/v1/projects/history",
5896                &serde_json::to_vec(&history_body).unwrap(),
5897            ),
5898            &root,
5899        );
5900        assert_eq!(history.status, 422);
5901
5902        // A multipart restore without a `bundle` field is 422.
5903        let boundary = "bnd";
5904        let body = multipart_form(&[("output_dir", b"/tmp")], boundary);
5905        let restore = route_one(
5906            &request_with_content_type(
5907                "POST",
5908                "/api/v1/projects/restore",
5909                &body,
5910                Some(&format!("multipart/form-data; boundary={boundary}")),
5911            ),
5912            &root,
5913        );
5914        assert_eq!(restore.status, 422);
5915    }
5916
5917    // --- Migration route integration tests ----------------------------------
5918
5919    /// Migration routes: preview, reference checks, single apply, batch preview
5920    /// and apply, and backup restore against real project fixtures in a temp dir.
5921    #[test]
5922    fn migration_routes_preview_checks_apply_batch_restore() {
5923        let root = test_web_root();
5924        let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
5925        let dir = std::env::temp_dir().join(format!(
5926            "seattrellis_migration_server_test_{}_{}",
5927            std::process::id(),
5928            seq
5929        ));
5930        let _ = fs::remove_dir_all(&dir);
5931        fs::create_dir_all(&dir).unwrap();
5932
5933        // Real referenced files so reference checks pass and batch apply is ready.
5934        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
5935        for name in [
5936            "students.csv",
5937            "classroom.json",
5938            "rules_multi_candidate.json",
5939        ] {
5940            fs::copy(examples.join(name), dir.join(name)).unwrap();
5941        }
5942        fs::create_dir_all(dir.join("history")).unwrap();
5943        fs::create_dir_all(dir.join("outputs")).unwrap();
5944
5945        // Two minimal (pre-migration) project files missing the canonical fields.
5946        let minimal = r#"{
5947            "kind": "seattrellis_project",
5948            "name": "Mig Demo",
5949            "students": "students.csv",
5950            "layout": "classroom.json",
5951            "rules": "rules_multi_candidate.json",
5952            "history_dir": "history",
5953            "outputs_dir": "outputs"
5954        }"#;
5955        fs::write(dir.join("mig1.seattrellis.json"), minimal).unwrap();
5956        fs::write(dir.join("mig2.seattrellis.json"), minimal).unwrap();
5957        let p1 = dir
5958            .join("mig1.seattrellis.json")
5959            .to_string_lossy()
5960            .into_owned();
5961        let p2 = dir
5962            .join("mig2.seattrellis.json")
5963            .to_string_lossy()
5964            .into_owned();
5965
5966        // 1. Preview a single migration -> changes detected, refs ok.
5967        let preview_body = json!({ "project_path": p1, "in_place": false });
5968        let preview = route_one(
5969            &request(
5970                "POST",
5971                "/api/v1/projects/migration/preview",
5972                &serde_json::to_vec(&preview_body).unwrap(),
5973            ),
5974            &root,
5975        );
5976        assert_eq!(
5977            preview.status,
5978            200,
5979            "body: {}",
5980            String::from_utf8_lossy(&preview.body)
5981        );
5982        let preview_val = body_json(&preview);
5983        assert_eq!(preview_val["api_version"], "1");
5984        assert_eq!(preview_val["dry_run"], true);
5985        assert!(
5986            preview_val["change_count"].as_u64().unwrap() > 0,
5987            "the minimal fixture should be missing canonical fields"
5988        );
5989        assert!(preview_val["reference_checks"]
5990            .as_array()
5991            .unwrap()
5992            .iter()
5993            .all(|check| check["status"] == "ok"));
5994
5995        // 2. Standalone reference checks route.
5996        let checks_body = json!({ "project_path": p1 });
5997        let checks = route_one(
5998            &request(
5999                "POST",
6000                "/api/v1/projects/migration/reference-checks",
6001                &serde_json::to_vec(&checks_body).unwrap(),
6002            ),
6003            &root,
6004        );
6005        assert_eq!(
6006            checks.status,
6007            200,
6008            "body: {}",
6009            String::from_utf8_lossy(&checks.body)
6010        );
6011        assert_eq!(body_json(&checks)["ready"], true);
6012
6013        // 3. Apply a single migration out-of-place -> a migrated sibling file.
6014        let apply_body = json!({ "project_path": p1, "in_place": false });
6015        let apply = route_one(
6016            &request(
6017                "POST",
6018                "/api/v1/projects/migration/apply",
6019                &serde_json::to_vec(&apply_body).unwrap(),
6020            ),
6021            &root,
6022        );
6023        assert_eq!(
6024            apply.status,
6025            200,
6026            "body: {}",
6027            String::from_utf8_lossy(&apply.body)
6028        );
6029        let apply_val = body_json(&apply);
6030        assert_eq!(apply_val["dry_run"], false);
6031        assert!(apply_val["change_count"].as_u64().unwrap() > 0);
6032        let output_path = apply_val["output_path"].as_str().unwrap();
6033        assert!(
6034            Path::new(output_path).is_file(),
6035            "migrated file should exist"
6036        );
6037
6038        // 4. Batch preview + batch apply over both fixtures.
6039        let batch_body = json!({ "project_paths": [p1, p2], "in_place": false });
6040        let batch_preview = route_one(
6041            &request(
6042                "POST",
6043                "/api/v1/projects/migration/batch/preview",
6044                &serde_json::to_vec(&batch_body).unwrap(),
6045            ),
6046            &root,
6047        );
6048        assert_eq!(
6049            batch_preview.status,
6050            200,
6051            "body: {}",
6052            String::from_utf8_lossy(&batch_preview.body)
6053        );
6054        let batch_preview_val = body_json(&batch_preview);
6055        assert_eq!(
6056            batch_preview_val["projects"].as_array().map(Vec::len),
6057            Some(2)
6058        );
6059        assert_eq!(batch_preview_val["ready"], true);
6060
6061        let batch_apply = route_one(
6062            &request(
6063                "POST",
6064                "/api/v1/projects/migration/batch/apply",
6065                &serde_json::to_vec(&batch_body).unwrap(),
6066            ),
6067            &root,
6068        );
6069        assert_eq!(
6070            batch_apply.status,
6071            200,
6072            "body: {}",
6073            String::from_utf8_lossy(&batch_apply.body)
6074        );
6075        let batch_apply_val = body_json(&batch_apply);
6076        assert_eq!(
6077            batch_apply_val["projects"].as_array().map(Vec::len),
6078            Some(2)
6079        );
6080
6081        // 5. Apply in place creates a backup, then restore it.
6082        let in_place_body = json!({ "project_path": p1, "in_place": true });
6083        let in_place = route_one(
6084            &request(
6085                "POST",
6086                "/api/v1/projects/migration/apply",
6087                &serde_json::to_vec(&in_place_body).unwrap(),
6088            ),
6089            &root,
6090        );
6091        assert_eq!(
6092            in_place.status,
6093            200,
6094            "body: {}",
6095            String::from_utf8_lossy(&in_place.body)
6096        );
6097        let in_place_val = body_json(&in_place);
6098        let backup_path = in_place_val["backup_path"].as_str().unwrap().to_string();
6099        assert!(
6100            Path::new(&backup_path).is_file(),
6101            "in-place apply should create a backup: {backup_path}"
6102        );
6103
6104        let restore_body = json!({
6105            "project_path": p1,
6106            "source_path": p1,
6107            "backup_path": backup_path,
6108        });
6109        let restore = route_one(
6110            &request(
6111                "POST",
6112                "/api/v1/projects/migration/restore",
6113                &serde_json::to_vec(&restore_body).unwrap(),
6114            ),
6115            &root,
6116        );
6117        assert_eq!(
6118            restore.status,
6119            200,
6120            "body: {}",
6121            String::from_utf8_lossy(&restore.body)
6122        );
6123        let restore_val = body_json(&restore);
6124        assert_eq!(restore_val["restored_valid"], true);
6125        assert_eq!(restore_val["source_path"], p1);
6126    }
6127
6128    /// Migration error mapping: a missing artifact is 404 and a batch with a
6129    /// single path is 422.
6130    #[test]
6131    fn migration_routes_validation_errors() {
6132        let root = test_web_root();
6133
6134        // Missing project artifact -> 404.
6135        let preview_body = json!({ "project_path": "/nonexistent/mig.json" });
6136        let preview = route_one(
6137            &request(
6138                "POST",
6139                "/api/v1/projects/migration/preview",
6140                &serde_json::to_vec(&preview_body).unwrap(),
6141            ),
6142            &root,
6143        );
6144        assert_eq!(preview.status, 404);
6145
6146        // Batch preview requires at least 2 paths -> 422.
6147        let batch_body = json!({ "project_paths": ["/tmp/only-one.json"] });
6148        let batch = route_one(
6149            &request(
6150                "POST",
6151                "/api/v1/projects/migration/batch/preview",
6152                &serde_json::to_vec(&batch_body).unwrap(),
6153            ),
6154            &root,
6155        );
6156        assert_eq!(batch.status, 422);
6157    }
6158
6159    /// `projects/recent` rejects a non-numeric limit as 422.
6160    #[test]
6161    fn projects_recent_invalid_limit_is_422() {
6162        let root = test_web_root();
6163        let response = route_one(
6164            &request("GET", "/api/v1/projects/recent?root=.&limit=abc", b""),
6165            &root,
6166        );
6167        assert_eq!(response.status, 422);
6168        let response = route_one(
6169            &request("GET", "/api/v1/projects/recent?root=.&limit=0", b""),
6170            &root,
6171        );
6172        assert_eq!(response.status, 422);
6173    }
6174
6175    /// A fresh temp project directory for the rotation routes.
6176    fn rotation_project_dir() -> PathBuf {
6177        let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
6178        let dir = std::env::temp_dir().join(format!(
6179            "seattrellis_rotation_server_test_{}_{}",
6180            std::process::id(),
6181            seq
6182        ));
6183        let _ = fs::remove_dir_all(&dir);
6184        fs::create_dir_all(&dir).unwrap();
6185        dir
6186    }
6187
6188    /// Write a minimal `seattrellis_project` file and return its path.
6189    fn rotation_project_file(dir: &Path) -> String {
6190        let project = json!({
6191            "kind": "seattrellis_project",
6192            "schema_version": 1,
6193            "students": "students.csv",
6194            "layout": "classroom.json",
6195            "rules": "rules.json",
6196            "outputs_dir": "outputs",
6197        });
6198        let project_file = dir.join("project.seattrellis.json");
6199        fs::write(&project_file, serde_json::to_vec(&project).unwrap()).unwrap();
6200        // Roster + layout must let the load wiring rebuild editable drafts
6201        // (one per period, matching the plan's assignments).
6202        fs::write(
6203            dir.join("students.csv"),
6204            "student_id,name\nSTU001,Alice\nSTU002,Bob\n",
6205        )
6206        .unwrap();
6207        fs::write(
6208            dir.join("classroom.json"),
6209            r#"{"seats":[
6210                {"seat_id":"R1C1","row":1,"col":1,"enabled":true},
6211                {"seat_id":"R1C3","row":1,"col":3,"enabled":true},
6212                {"seat_id":"R2C2","row":2,"col":2,"enabled":true}
6213            ]}"#,
6214        )
6215        .unwrap();
6216        fs::write(dir.join("rules.json"), r#"{}"#).unwrap();
6217        project_file.to_string_lossy().into_owned()
6218    }
6219
6220    /// A two-period rotation plan matching the module's test fixture.
6221    fn rotation_plan_value() -> Value {
6222        json!({
6223            "schema_version": "1.0",
6224            "kind": "rotation_plan",
6225            "name": "Weekly Rotation",
6226            "periods": [
6227                {
6228                    "period": 1,
6229                    "label": "Week 1",
6230                    "snapshot": {
6231                        "solver_status": "FEASIBLE",
6232                        "assignments": [
6233                            {"student_key": "STU001", "student_name": "Alice", "seat_id": "R1C1"},
6234                            {"student_key": "STU002", "student_name": "Bob", "seat_id": "R1C3"}
6235                        ],
6236                        "students": [
6237                            {"student_id": "STU001", "name": "Alice"},
6238                            {"student_id": "STU002", "name": "Bob"}
6239                        ],
6240                        "layout": {"seats": [
6241                            {"seat_id": "R1C1", "row": 1, "col": 1, "enabled": true},
6242                            {"seat_id": "R1C3", "row": 1, "col": 3, "enabled": true}
6243                        ]}
6244                    }
6245                },
6246                {
6247                    "period": 2,
6248                    "label": "Week 2",
6249                    "snapshot": {
6250                        "solver_status": "FEASIBLE",
6251                        "assignments": [
6252                            {"student_key": "STU001", "student_name": "Alice", "seat_id": "R2C2"}
6253                        ]
6254                    }
6255                }
6256            ]
6257        })
6258    }
6259
6260    /// Save + load round trip, preview, and the HTML/CSV download magic bytes.
6261    #[test]
6262    fn rotation_routes_save_load_preview_download() {
6263        let root = test_web_root();
6264        let dir = rotation_project_dir();
6265        let project_path = rotation_project_file(&dir);
6266
6267        // 1. Save accepts the workbench shape (extra fields ignored) and
6268        //    returns the module's `ProjectRotationSaveResponse` envelope.
6269        let save_body = json!({
6270            "project_path": project_path,
6271            "rotation_plan": rotation_plan_value(),
6272            "draft_ids": ["draft-1", "draft-2"],
6273        });
6274        let save = route_one(
6275            &request(
6276                "POST",
6277                "/api/v1/projects/rotation/save",
6278                &serde_json::to_vec(&save_body).unwrap(),
6279            ),
6280            &root,
6281        );
6282        assert_eq!(
6283            save.status,
6284            200,
6285            "body: {}",
6286            String::from_utf8_lossy(&save.body)
6287        );
6288        let save_val = body_json(&save);
6289        assert_eq!(save_val["api_version"], "1");
6290        assert_eq!(save_val["period_count"], 2);
6291        assert!(save_val["saved_at"].as_str().unwrap().ends_with("+00:00"));
6292        let output_path = save_val["output_path"].as_str().unwrap().to_string();
6293        assert!(output_path.ends_with("rotation-plan.json"));
6294
6295        // 2. Load returns the plan stored on disk.
6296        let load = route_one(
6297            &request(
6298                "POST",
6299                "/api/v1/projects/rotation/load",
6300                &serde_json::to_vec(&json!({
6301                    "project_path": project_path,
6302                    "artifact_path": output_path,
6303                }))
6304                .unwrap(),
6305            ),
6306            &root,
6307        );
6308        assert_eq!(
6309            load.status,
6310            200,
6311            "body: {}",
6312            String::from_utf8_lossy(&load.body)
6313        );
6314        let load_val = body_json(&load);
6315        assert_eq!(load_val["artifact_path"].as_str().unwrap(), output_path);
6316        assert_eq!(
6317            load_val["project_path"].as_str().unwrap(),
6318            save_val["project_path"]
6319        );
6320        assert_eq!(load_val["rotation_plan"]["name"], "Weekly Rotation");
6321        assert_eq!(
6322            load_val["rotation_plan"]["periods"]
6323                .as_array()
6324                .unwrap()
6325                .len(),
6326            2
6327        );
6328        // The load wiring rebuilds one editable draft per period (M2 §5.7:
6329        // `editor` = period 1, `period_editors` = every period, candidate
6330        // id "period-N"), so the workbench can load and switch periods.
6331        let period_editors = load_val["period_editors"].as_array().unwrap();
6332        assert_eq!(period_editors.len(), 2, "one draft per period");
6333        assert_eq!(load_val["editor"]["candidate_id"], "period-1");
6334        assert_eq!(period_editors[0]["candidate_id"], "period-1");
6335        assert_eq!(period_editors[1]["candidate_id"], "period-2");
6336        // Names come from the project roster, not raw keys.
6337        let names: Vec<&str> = period_editors[0]["students"]
6338            .as_array()
6339            .unwrap()
6340            .iter()
6341            .filter_map(|student| student["display_name"].as_str())
6342            .collect();
6343        assert!(names.contains(&"Alice"), "roster names survive reload");
6344        assert!(names.contains(&"Bob"));
6345
6346        // 3. Preview defaults to period 1 and groups by row and column.
6347        let preview = route_one(
6348            &request(
6349                "POST",
6350                "/api/v1/projects/rotation/group-register/preview",
6351                &serde_json::to_vec(&json!({
6352                    "project_path": project_path,
6353                    "artifact_path": output_path,
6354                }))
6355                .unwrap(),
6356            ),
6357            &root,
6358        );
6359        assert_eq!(
6360            preview.status,
6361            200,
6362            "body: {}",
6363            String::from_utf8_lossy(&preview.body)
6364        );
6365        let preview_val = body_json(&preview);
6366        assert_eq!(preview_val["api_version"], "1");
6367        assert_eq!(preview_val["period"], 1);
6368        assert_eq!(preview_val["period_label"], "Week 1");
6369        assert_eq!(preview_val["plan_name"], "Weekly Rotation");
6370        assert_eq!(preview_val["period_count"], 2);
6371        assert!(!preview_val["row_groups"].as_array().unwrap().is_empty());
6372        assert!(!preview_val["column_groups"].as_array().unwrap().is_empty());
6373
6374        // An explicit period_index selects the other period.
6375        let preview2 = route_one(
6376            &request(
6377                "POST",
6378                "/api/v1/projects/rotation/group-register/preview",
6379                &serde_json::to_vec(&json!({
6380                    "project_path": project_path,
6381                    "period_index": 2,
6382                }))
6383                .unwrap(),
6384            ),
6385            &root,
6386        );
6387        assert_eq!(preview2.status, 200);
6388        assert_eq!(body_json(&preview2)["period"], 2);
6389
6390        // 4. HTML download: text/html, doctype magic, attachment filename.
6391        let html = route_one(
6392            &request(
6393                "POST",
6394                "/api/v1/projects/rotation/group-register",
6395                &serde_json::to_vec(&json!({
6396                    "project_path": project_path,
6397                    "format": "html",
6398                }))
6399                .unwrap(),
6400            ),
6401            &root,
6402        );
6403        assert_eq!(
6404            html.status,
6405            200,
6406            "body: {}",
6407            String::from_utf8_lossy(&html.body)
6408        );
6409        assert_eq!(html.content_type, Some("text/html; charset=utf-8"));
6410        assert!(html.body.starts_with(b"<!doctype html>"));
6411        assert!(html
6412            .content_disposition
6413            .as_deref()
6414            .unwrap()
6415            .contains("filename=\"group-register.html\""));
6416
6417        // 5. CSV download: text/csv with a UTF-8 BOM magic prefix.
6418        let csv = route_one(
6419            &request(
6420                "POST",
6421                "/api/v1/projects/rotation/group-register",
6422                &serde_json::to_vec(&json!({
6423                    "project_path": project_path,
6424                    "format": "csv",
6425                }))
6426                .unwrap(),
6427            ),
6428            &root,
6429        );
6430        assert_eq!(
6431            csv.status,
6432            200,
6433            "body: {}",
6434            String::from_utf8_lossy(&csv.body)
6435        );
6436        assert_eq!(csv.content_type, Some("text/csv; charset=utf-8"));
6437        assert_eq!(&csv.body[..3], &[0xEF, 0xBB, 0xBF]);
6438        assert!(csv
6439            .content_disposition
6440            .as_deref()
6441            .unwrap()
6442            .contains("filename=\"group-register.csv\""));
6443
6444        // 6. Persist a group register (JSON body) and read it back from disk.
6445        let groups = json!({ "groups": [{ "name": "A", "students": ["STU001"] }] });
6446        let saved_groups = route_one(
6447            &request(
6448                "POST",
6449                "/api/v1/projects/rotation/group-register/save",
6450                &serde_json::to_vec(&json!({
6451                    "project_path": project_path,
6452                    "groups": groups,
6453                }))
6454                .unwrap(),
6455            ),
6456            &root,
6457        );
6458        assert_eq!(
6459            saved_groups.status,
6460            200,
6461            "body: {}",
6462            String::from_utf8_lossy(&saved_groups.body)
6463        );
6464        let saved_groups_val = body_json(&saved_groups);
6465        assert_eq!(saved_groups_val["group_count"], 1);
6466        assert!(saved_groups_val["output_path"]
6467            .as_str()
6468            .unwrap()
6469            .ends_with("group-register.json"));
6470        let on_disk: Value = serde_json::from_slice(
6471            &fs::read(dir.join("outputs").join("group-register.json")).unwrap(),
6472        )
6473        .unwrap();
6474        assert_eq!(on_disk["groups"][0]["name"], "A");
6475
6476        // 7. The same save endpoint accepts a multipart form.
6477        let boundary = "rotation-multipart-boundary";
6478        let mut multipart_body = Vec::new();
6479        multipart_body.extend_from_slice(
6480            format!(
6481                "--{boundary}\r\nContent-Disposition: form-data; name=\"project_path\"\r\n\r\n{project_path}\r\n"
6482            )
6483            .as_bytes(),
6484        );
6485        multipart_body.extend_from_slice(
6486            format!(
6487                "--{boundary}\r\nContent-Disposition: form-data; name=\"groups\"\r\n\r\n{groups}\r\n"
6488            )
6489            .as_bytes(),
6490        );
6491        multipart_body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
6492        let multipart_save = route_one(
6493            &request_with_content_type(
6494                "POST",
6495                "/api/v1/projects/rotation/group-register/save",
6496                &multipart_body,
6497                Some(&format!("multipart/form-data; boundary={boundary}")),
6498            ),
6499            &root,
6500        );
6501        assert_eq!(
6502            multipart_save.status,
6503            200,
6504            "body: {}",
6505            String::from_utf8_lossy(&multipart_save.body)
6506        );
6507        assert_eq!(body_json(&multipart_save)["group_count"], 1);
6508    }
6509
6510    /// Reloading the same saved rotation plan twice must succeed: rebuilt
6511    /// drafts get unique storage ids while `candidate_id` stays "period-N"
6512    /// for the workbench to match on (K: the fixed "period-N" draft id used
6513    /// to collide with "an editor draft already exists" on the second load).
6514    #[test]
6515    fn rotation_load_twice_creates_independent_drafts() {
6516        let root = test_web_root();
6517        let dir = rotation_project_dir();
6518        let project_path = rotation_project_file(&dir);
6519
6520        let save = route_one(
6521            &request(
6522                "POST",
6523                "/api/v1/projects/rotation/save",
6524                &serde_json::to_vec(&json!({
6525                    "project_path": project_path,
6526                    "rotation_plan": rotation_plan_value(),
6527                }))
6528                .unwrap(),
6529            ),
6530            &root,
6531        );
6532        assert_eq!(
6533            save.status,
6534            200,
6535            "body: {}",
6536            String::from_utf8_lossy(&save.body)
6537        );
6538        let output_path = body_json(&save)["output_path"]
6539            .as_str()
6540            .unwrap()
6541            .to_string();
6542        let load_body = serde_json::to_vec(&json!({
6543            "project_path": project_path,
6544            "artifact_path": output_path,
6545        }))
6546        .unwrap();
6547
6548        // Both loads share one editor store, like a real session.
6549        let editor_store = editing::new_draft_store();
6550        let solve_requests: SolveRequestStore = Mutex::new(HashMap::new());
6551
6552        let first = route_with_store(
6553            &request("POST", "/api/v1/projects/rotation/load", &load_body),
6554            &root,
6555            &editor_store,
6556            &solve_requests,
6557        );
6558        assert_eq!(
6559            first.status,
6560            200,
6561            "body: {}",
6562            String::from_utf8_lossy(&first.body)
6563        );
6564
6565        let second = route_with_store(
6566            &request("POST", "/api/v1/projects/rotation/load", &load_body),
6567            &root,
6568            &editor_store,
6569            &solve_requests,
6570        );
6571        assert_eq!(
6572            second.status,
6573            200,
6574            "second load of the same plan must not collide, body: {}",
6575            String::from_utf8_lossy(&second.body)
6576        );
6577
6578        let first_val = body_json(&first);
6579        let second_val = body_json(&second);
6580
6581        // candidate_id stays stable for frontend period matching.
6582        assert_eq!(first_val["editor"]["candidate_id"], "period-1");
6583        assert_eq!(second_val["editor"]["candidate_id"], "period-1");
6584
6585        // Storage ids are freshly minted and distinct, per load and per period.
6586        let first_draft_id = first_val["editor"]["draft_id"].as_str().unwrap();
6587        let second_draft_id = second_val["editor"]["draft_id"].as_str().unwrap();
6588        assert_ne!(first_draft_id, second_draft_id);
6589        let first_editors = first_val["period_editors"].as_array().unwrap();
6590        assert_ne!(first_editors[0]["draft_id"], first_editors[1]["draft_id"]);
6591
6592        // Both drafts exist independently in the store.
6593        for (draft_id, expected_revision) in [(first_draft_id, 0u64), (second_draft_id, 0u64)] {
6594            let fetched = route_with_store(
6595                &request("GET", &format!("/api/v1/editing/drafts/{draft_id}"), b""),
6596                &root,
6597                &editor_store,
6598                &solve_requests,
6599            );
6600            assert_eq!(fetched.status, 200);
6601            assert_eq!(body_json(&fetched)["draft_id"], draft_id);
6602            assert_eq!(body_json(&fetched)["revision"], expected_revision);
6603        }
6604    }
6605
6606    /// Missing artifacts and bad request shapes map to 400/404/422.
6607    #[test]
6608    fn rotation_routes_error_mapping() {
6609        let root = test_web_root();
6610        let dir = rotation_project_dir();
6611        let project_path = rotation_project_file(&dir);
6612
6613        // No saved plan yet -> load is 404.
6614        let load = route_one(
6615            &request(
6616                "POST",
6617                "/api/v1/projects/rotation/load",
6618                &serde_json::to_vec(&json!({ "project_path": project_path })).unwrap(),
6619            ),
6620            &root,
6621        );
6622        assert_eq!(load.status, 404);
6623
6624        // Preview and register before any save are also 404.
6625        let preview = route_one(
6626            &request(
6627                "POST",
6628                "/api/v1/projects/rotation/group-register/preview",
6629                &serde_json::to_vec(&json!({ "project_path": project_path })).unwrap(),
6630            ),
6631            &root,
6632        );
6633        assert_eq!(preview.status, 404);
6634        let register = route_one(
6635            &request(
6636                "POST",
6637                "/api/v1/projects/rotation/group-register",
6638                &serde_json::to_vec(&json!({ "project_path": project_path })).unwrap(),
6639            ),
6640            &root,
6641        );
6642        assert_eq!(register.status, 404);
6643
6644        // Missing `rotation_plan` -> 400.
6645        let bad_save = route_one(
6646            &request(
6647                "POST",
6648                "/api/v1/projects/rotation/save",
6649                &serde_json::to_vec(&json!({ "project_path": project_path })).unwrap(),
6650            ),
6651            &root,
6652        );
6653        assert_eq!(bad_save.status, 400);
6654
6655        // Invalid JSON body -> 400.
6656        let bad_json = route_one(
6657            &request("POST", "/api/v1/projects/rotation/save", b"not json"),
6658            &root,
6659        );
6660        assert_eq!(bad_json.status, 400);
6661
6662        // Invalid plan shape -> 422.
6663        let bad_plan = route_one(
6664            &request(
6665                "POST",
6666                "/api/v1/projects/rotation/save",
6667                &serde_json::to_vec(&json!({
6668                    "project_path": project_path,
6669                    "rotation_plan": { "periods": [] },
6670                }))
6671                .unwrap(),
6672            ),
6673            &root,
6674        );
6675        assert_eq!(bad_plan.status, 422);
6676
6677        // Save a valid plan so we can probe period/format errors.
6678        route_one(
6679            &request(
6680                "POST",
6681                "/api/v1/projects/rotation/save",
6682                &serde_json::to_vec(&json!({
6683                    "project_path": project_path,
6684                    "rotation_plan": rotation_plan_value(),
6685                }))
6686                .unwrap(),
6687            ),
6688            &root,
6689        );
6690
6691        // Out-of-range period -> 404.
6692        let bad_period = route_one(
6693            &request(
6694                "POST",
6695                "/api/v1/projects/rotation/group-register/preview",
6696                &serde_json::to_vec(&json!({
6697                    "project_path": project_path,
6698                    "period_index": 99,
6699                }))
6700                .unwrap(),
6701            ),
6702            &root,
6703        );
6704        assert_eq!(bad_period.status, 404);
6705
6706        // Unknown download format -> 400.
6707        let bad_format = route_one(
6708            &request(
6709                "POST",
6710                "/api/v1/projects/rotation/group-register",
6711                &serde_json::to_vec(&json!({
6712                    "project_path": project_path,
6713                    "format": "pdf",
6714                }))
6715                .unwrap(),
6716            ),
6717            &root,
6718        );
6719        assert_eq!(bad_format.status, 400);
6720
6721        // Missing groups on the save endpoint -> 400.
6722        let bad_groups = route_one(
6723            &request(
6724                "POST",
6725                "/api/v1/projects/rotation/group-register/save",
6726                &serde_json::to_vec(&json!({ "project_path": project_path })).unwrap(),
6727            ),
6728            &root,
6729        );
6730        assert_eq!(bad_groups.status, 400);
6731
6732        // Invalid groups payload -> 422.
6733        let invalid_groups = route_one(
6734            &request(
6735                "POST",
6736                "/api/v1/projects/rotation/group-register/save",
6737                &serde_json::to_vec(&json!({
6738                    "project_path": project_path,
6739                    "groups": { "not_groups": true },
6740                }))
6741                .unwrap(),
6742            ),
6743            &root,
6744        );
6745        assert_eq!(invalid_groups.status, 422);
6746    }
6747
6748    // -------------------------------------------------------------------
6749    // PD-D14 trusted-root file read (`POST /api/v1/files/read`)
6750    // -------------------------------------------------------------------
6751
6752    /// A temp trusted root containing a small roster-like file.
6753    fn test_trusted_root() -> (PathBuf, PathBuf) {
6754        let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed);
6755        let dir = std::env::temp_dir().join(format!(
6756            "seattrellis_trusted_{}_{}",
6757            std::process::id(),
6758            seq
6759        ));
6760        let _ = fs::remove_dir_all(&dir);
6761        fs::create_dir_all(dir.join("rosters")).unwrap();
6762        let roster = dir.join("rosters/class-8-3.csv");
6763        fs::write(&roster, "student_id,name\nS01,Lin\n").unwrap();
6764        (dir, roster)
6765    }
6766
6767    #[test]
6768    fn trusted_path_validation_rejects_absolute_traversal_and_drive_prefixes() {
6769        assert_eq!(
6770            trusted_relative_path("rosters/a.csv"),
6771            Some("rosters/a.csv".into())
6772        );
6773        assert_eq!(trusted_relative_path("a.csv"), Some("a.csv".into()));
6774        // Absolute forms.
6775        assert_eq!(trusted_relative_path("/etc/passwd"), None);
6776        assert_eq!(trusted_relative_path("C:/windows/system32"), None);
6777        assert_eq!(trusted_relative_path("C:foo"), None);
6778        // Traversal.
6779        assert_eq!(trusted_relative_path("../secrets.csv"), None);
6780        assert_eq!(trusted_relative_path("rosters/../../secrets.csv"), None);
6781        assert_eq!(trusted_relative_path("rosters/.."), None);
6782        // Backslash separators / NUL / empty / dot-only.
6783        assert_eq!(trusted_relative_path("rosters\\a.csv"), None);
6784        assert_eq!(trusted_relative_path("a\0b.csv"), None);
6785        assert_eq!(trusted_relative_path(""), None);
6786        assert_eq!(trusted_relative_path("."), None);
6787        // Harmless dots collapse.
6788        assert_eq!(
6789            trusted_relative_path("rosters/./a.csv"),
6790            Some("rosters/a.csv".into())
6791        );
6792    }
6793
6794    #[test]
6795    fn file_read_returns_utf8_roster_within_trusted_root() {
6796        let (trusted, roster_path) = test_trusted_root();
6797        let root = test_web_root();
6798        let body = serde_json::to_vec(&json!({ "path": "rosters/class-8-3.csv" })).unwrap();
6799        let response = route_one_with_root(
6800            &request("POST", "/api/v1/files/read", &body),
6801            &root,
6802            &trusted,
6803        );
6804        assert_eq!(response.status, 200);
6805        let value: Value = serde_json::from_slice(&response.body).unwrap();
6806        assert_eq!(value["name"], "class-8-3.csv");
6807        assert_eq!(value["size"], fs::metadata(&roster_path).unwrap().len());
6808        let decoded = base64::engine::general_purpose::STANDARD
6809            .decode(value["content_base64"].as_str().unwrap())
6810            .unwrap();
6811        assert_eq!(
6812            String::from_utf8(decoded).unwrap(),
6813            "student_id,name\nS01,Lin\n"
6814        );
6815    }
6816
6817    #[test]
6818    fn file_read_rejects_absolute_traversal_and_escapes() {
6819        let (trusted, _) = test_trusted_root();
6820        let root = test_web_root();
6821        for bad in [
6822            "/etc/hosts",
6823            "C:/windows/win.ini",
6824            "../outside.csv",
6825            "rosters/../../outside.csv",
6826            "a\\b.csv",
6827        ] {
6828            let body = serde_json::to_vec(&json!({ "path": bad })).unwrap();
6829            let response = route_one_with_root(
6830                &request("POST", "/api/v1/files/read", &body),
6831                &root,
6832                &trusted,
6833            );
6834            assert_eq!(response.status, 400, "path {bad:?} must be rejected");
6835        }
6836    }
6837
6838    #[test]
6839    fn file_read_rejects_missing_files_and_directories() {
6840        let (trusted, _) = test_trusted_root();
6841        let root = test_web_root();
6842        let missing = route_one_with_root(
6843            &request(
6844                "POST",
6845                "/api/v1/files/read",
6846                &serde_json::to_vec(&json!({ "path": "rosters/nope.csv" })).unwrap(),
6847            ),
6848            &root,
6849            &trusted,
6850        );
6851        assert_eq!(missing.status, 404);
6852        // A directory is not a file.
6853        let dir = route_one_with_root(
6854            &request(
6855                "POST",
6856                "/api/v1/files/read",
6857                &serde_json::to_vec(&json!({ "path": "rosters" })).unwrap(),
6858            ),
6859            &root,
6860            &trusted,
6861        );
6862        assert_eq!(dir.status, 400);
6863        // Malformed bodies.
6864        let empty =
6865            route_one_with_root(&request("POST", "/api/v1/files/read", b""), &root, &trusted);
6866        assert_eq!(empty.status, 400);
6867        let no_path = route_one_with_root(
6868            &request(
6869                "POST",
6870                "/api/v1/files/read",
6871                &serde_json::to_vec(&json!({ "other": 1 })).unwrap(),
6872            ),
6873            &root,
6874            &trusted,
6875        );
6876        assert_eq!(no_path.status, 400);
6877        let bad_json = route_one_with_root(
6878            &request("POST", "/api/v1/files/read", b"not json"),
6879            &root,
6880            &trusted,
6881        );
6882        assert_eq!(bad_json.status, 400);
6883    }
6884
6885    #[test]
6886    fn file_root_reports_the_canonical_trusted_root() {
6887        let (trusted, _) = test_trusted_root();
6888        let root = test_web_root();
6889        let response =
6890            route_one_with_root(&request("GET", "/api/v1/files/root", b""), &root, &trusted);
6891        assert_eq!(response.status, 200);
6892        let value: Value = serde_json::from_slice(&response.body).unwrap();
6893        assert_eq!(
6894            value["root"].as_str().unwrap(),
6895            trusted.canonicalize().unwrap().to_string_lossy().as_ref()
6896        );
6897    }
6898
6899    #[test]
6900    fn file_read_rejects_oversize_files() {
6901        let (trusted, _) = test_trusted_root();
6902        let root = test_web_root();
6903        let big = trusted.join("big.bin");
6904        fs::write(&big, vec![0u8; MAX_TRUSTED_READ_BYTES + 1]).unwrap();
6905        let body = serde_json::to_vec(&json!({ "path": "big.bin" })).unwrap();
6906        let response = route_one_with_root(
6907            &request("POST", "/api/v1/files/read", &body),
6908            &root,
6909            &trusted,
6910        );
6911        assert_eq!(response.status, 413);
6912    }
6913
6914    #[test]
6915    fn file_read_rejects_paths_outside_trusted_root_even_when_relative() {
6916        // A symlink inside the root pointing outside must not be readable:
6917        // the canonical containment check is the last line of defense.
6918        // (Windows has no unix symlinks in this CI sandbox; the binding is
6919        // only used inside the cfg(unix) block.)
6920        let (trusted, _) = test_trusted_root();
6921        let root = test_web_root();
6922        let outside = root.join("outside-target.txt");
6923        fs::write(&outside, "secret").unwrap();
6924        #[cfg(unix)]
6925        {
6926            std::os::unix::fs::symlink(&outside, trusted.join("link.txt")).unwrap();
6927            let body = serde_json::to_vec(&json!({ "path": "link.txt" })).unwrap();
6928            let response = route_one_with_root(
6929                &request("POST", "/api/v1/files/read", &body),
6930                &root,
6931                &trusted,
6932            );
6933            // Symlink to a file outside the root -> 403 (or 404 on platforms
6934            // where the target is unresolvable); never 200.
6935            assert_ne!(response.status, 200);
6936        }
6937        #[cfg(windows)]
6938        let _ = trusted;
6939    }
6940}