1use 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
36const BUILTIN_WEB_STATIC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../clients/web/dist");
39const EMBEDDED_WEB_STATIC: &str = "<embedded>/clients/web/dist";
42
43pub(crate) use seattrellis_application::SolveRequestStore;
46
47#[derive(Debug)]
49pub enum ServerError {
50 Bind(io::Error),
52 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#[derive(Debug, Clone)]
69pub struct ServerConfig {
70 pub host: IpAddr,
72 pub port: u16,
74 pub web_root: PathBuf,
76 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 pub fn with_trusted_root(mut self, root: PathBuf) -> Self {
96 self.trusted_root = root;
97 self
98 }
99}
100
101pub 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 trusted_root: Arc<PathBuf>,
112 shutdown: Arc<AtomicBool>,
114 session_token: String,
117}
118
119impl Server {
120 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 pub fn addr(&self) -> SocketAddr {
139 self.local_addr
140 }
141
142 pub fn session_token(&self) -> &str {
144 &self.session_token
145 }
146
147 pub fn request_shutdown(&self) {
149 self.shutdown.store(true, Ordering::Relaxed);
150 }
151
152 pub fn shutdown_flag(&self) -> Arc<AtomicBool> {
155 Arc::clone(&self.shutdown)
156 }
157
158 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 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
191fn 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
198pub 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 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
240pub(crate) struct Request {
247 pub(crate) method: String,
248 pub(crate) path: String,
249 pub(crate) content_type: Option<String>,
251 pub(crate) body: Vec<u8>,
252}
253
254pub(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
296fn path_segments(path: &str) -> Vec<&str> {
298 path.trim_start_matches('/')
299 .split('/')
300 .filter(|segment| !segment.is_empty())
301 .collect()
302}
303
304fn 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
329fn 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
357fn 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
438fn 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
497fn 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
522pub(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 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
651fn 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
662fn 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
706fn 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
756fn 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
874fn 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
916const MAX_TRUSTED_READ_BYTES: usize = 8 * 1024 * 1024;
919
920fn 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
987fn 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
997fn 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
1068fn 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
1083fn 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 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
1137fn 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
1160fn 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
1169fn 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
1198fn 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
1210fn 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
1225fn 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
1250fn 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
1265fn 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
1282fn 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
1311fn 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
1334fn 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
1351fn 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
1380fn 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
1440fn 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
1446fn 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
1460fn 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
1474fn 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
1488fn 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
1510fn 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
1532fn 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
1552fn 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
1573fn 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
1598fn 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
1622fn 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
1636fn 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
1664fn 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
1689fn 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 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 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 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 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
1846static REBUILT_DRAFT_SEQ: AtomicU64 = AtomicU64::new(0);
1848
1849fn 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
1861fn 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
1913fn 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
1936fn 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
1996fn 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
2013fn 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
2022fn 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
2036fn 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
2049fn 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
2062fn 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
2074fn 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
2125fn 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
2154fn 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 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; }
2176 if !rest.starts_with(b"\r\n") {
2177 return Err("malformed multipart boundary line".to_string());
2178 }
2179 pos += 2;
2180
2181 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 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 pos = content_end + 2 + delimiter_bytes.len();
2201 }
2202
2203 Ok(fields)
2204}
2205
2206fn 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
2218fn 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
2234fn 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
2245fn 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
2267fn 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
2292fn 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
2326fn 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#[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 fn route_one(request: &Request, root: &Path) -> Response {
2382 route_one_with_root(request, root, root)
2383 }
2384
2385 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 let seats = editor["seats"].as_array().unwrap();
2997 assert_eq!(seats[0]["seat_id"], "R1C1");
2998 assert_eq!(seats[0]["enabled"], true);
2999 assert_eq!(seats[29]["seat_id"], "R5C7");
3001 }
3002
3003 #[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 #[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 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 assert_eq!(plan["fairness_summary"]["history_count"], 3);
3084 assert_eq!(plan["pair_repeat_summary"]["history_count"], 3);
3085
3086 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 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 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 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 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 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 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 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 assert_eq!(value["api_version"], "1");
3530
3531 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 assert_eq!(document["assignments"].as_array().unwrap().len(), 2);
3854 assert_eq!(document["assignments"][0]["seat_id"], "R1C1");
3855
3856 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 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 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 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 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 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 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 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 #[test]
4023 fn artifact_restore_rejects_files_outside_project_workspace() {
4024 let root = test_web_root();
4025 let project = ArtifactProject::new("restore-outside");
4026 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 #[test]
5439 fn layout_draft_lifecycle_create_get_command_compiled_delete() {
5440 let root = test_web_root();
5441
5442 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 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 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 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 assert_eq!(
5522 compiled_val["layout"]["seats"].as_array().map(Vec::len),
5523 Some(12)
5524 );
5525
5526 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 #[test]
5542 fn layout_command_errors_map_to_4xx() {
5543 let root = test_web_root();
5544
5545 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 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 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 #[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 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 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 #[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 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 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 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 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 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 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 #[test]
5854 fn project_routes_validation_errors() {
5855 let root = test_web_root();
5856
5857 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 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 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 #[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 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 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 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 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 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 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 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 #[test]
6131 fn migration_routes_validation_errors() {
6132 let root = test_web_root();
6133
6134 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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(first_val["editor"]["candidate_id"], "period-1");
6583 assert_eq!(second_val["editor"]["candidate_id"], "period-1");
6584
6585 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_ne!(response.status, 200);
6936 }
6937 #[cfg(windows)]
6938 let _ = trusted;
6939 }
6940}