1pub mod config;
60pub mod dashboard;
61pub mod data;
62pub mod error;
63pub mod field;
64pub mod model;
65pub mod registry;
66pub mod render;
67pub mod ui;
68pub mod views;
69
70pub use config::*;
71pub use dashboard::*;
72pub use data::{DataPage, DataQuery, DataSource, InMemoryDataSource};
73pub use error::*;
74pub use field::*;
75pub use model::*;
76pub use registry::*;
77pub use ui::*;
78pub use views::*;
79
80use armature_core::{Error, HttpRequest, HttpResponse, Router};
81use serde::{Deserialize, Serialize};
82use std::collections::HashMap;
83use std::sync::Arc;
84
85pub struct Admin {
87 config: AdminConfig,
89 registry: ModelRegistry,
91 data_source: Arc<dyn DataSource>,
93}
94
95impl Admin {
96 pub fn new() -> Self {
98 Self {
99 config: AdminConfig::default(),
100 registry: ModelRegistry::new(),
101 data_source: Arc::new(InMemoryDataSource::new()),
102 }
103 }
104
105 pub fn title(mut self, title: impl Into<String>) -> Self {
107 self.config.title = title.into();
108 self
109 }
110
111 pub fn base_path(mut self, path: impl Into<String>) -> Self {
113 self.config.base_path = path.into();
114 self
115 }
116
117 pub fn theme(mut self, theme: Theme) -> Self {
119 self.config.theme = theme;
120 self
121 }
122
123 pub fn items_per_page(mut self, count: usize) -> Self {
125 self.config.items_per_page = count;
126 self
127 }
128
129 pub fn max_items_per_page(mut self, count: usize) -> Self {
131 self.config.max_items_per_page = count;
132 self
133 }
134
135 pub fn require_auth(mut self, required: bool) -> Self {
137 self.config.require_auth = required;
138 self
139 }
140
141 pub fn data_source(mut self, source: Arc<dyn DataSource>) -> Self {
143 self.data_source = source;
144 self
145 }
146
147 pub fn register_model(mut self, model: ModelDefinition) -> Self {
149 self.registry.register(model);
150 self
151 }
152
153 pub fn build(self) -> AdminInstance {
155 AdminInstance {
156 config: Arc::new(self.config),
157 registry: Arc::new(self.registry),
158 data_source: self.data_source,
159 }
160 }
161}
162
163impl Default for Admin {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169#[derive(Clone)]
171pub struct AdminInstance {
172 pub config: Arc<AdminConfig>,
174 pub registry: Arc<ModelRegistry>,
176 pub data_source: Arc<dyn DataSource>,
178}
179
180impl AdminInstance {
181 pub fn config(&self) -> &AdminConfig {
183 &self.config
184 }
185
186 pub fn registry(&self) -> &ModelRegistry {
188 &self.registry
189 }
190
191 pub fn data_source(&self) -> &Arc<dyn DataSource> {
193 &self.data_source
194 }
195
196 pub fn routes(&self) -> Router {
210 let base = self.config.base_path.trim_end_matches('/').to_string();
211 let inst = Arc::new(self.clone());
212 let mut router = Router::new();
213
214 macro_rules! h {
215 ($f:ident) => {{
216 let inst = inst.clone();
217 move |req: HttpRequest| {
218 let inst = inst.clone();
219 async move { $f(inst, req).await }
220 }
221 }};
222 }
223
224 router.get(base.clone(), h!(handle_dashboard));
225 router.get(format!("{base}/:model"), h!(handle_list));
226 router.get(format!("{base}/:model/add"), h!(handle_create_form));
229 router.post(format!("{base}/:model/add"), h!(handle_create_submit));
230 router.get(format!("{base}/:model/:id/edit"), h!(handle_edit_form));
231 router.post(format!("{base}/:model/:id/edit"), h!(handle_update_submit));
232 router.post(format!("{base}/:model/:id/delete"), h!(handle_delete));
233 router.delete(format!("{base}/:model/:id"), h!(handle_delete));
234 router.get(format!("{base}/:model/:id"), h!(handle_detail));
235
236 router
237 }
238
239 pub fn get_model(&self, name: &str) -> Option<&ModelDefinition> {
241 self.registry.get(name)
242 }
243
244 pub fn models(&self) -> Vec<&ModelDefinition> {
246 self.registry.all()
247 }
248}
249
250fn is_authorized(config: &AdminConfig, req: &HttpRequest) -> bool {
266 if !config.require_auth {
267 return true;
268 }
269 req.headers
270 .get_ignore_case("authorization")
271 .map(|v| !v.trim().is_empty())
272 .unwrap_or(false)
273}
274
275fn authorize_and_resolve_model(
282 inst: &AdminInstance,
283 req: &HttpRequest,
284) -> Result<ModelDefinition, Box<HttpResponse>> {
285 if !is_authorized(&inst.config, req) {
286 return Err(Box::new(html_response(
287 401,
288 render::render_unauthorized(&inst.config),
289 )));
290 }
291 let model_name = req.param("model").map(str::to_owned).unwrap_or_default();
292 match inst.get_model(&model_name) {
293 Some(m) => Ok(m.clone()),
294 None => Err(Box::new(html_response(
295 404,
296 render::render_not_found(&inst.config),
297 ))),
298 }
299}
300
301fn list_params_from_request(req: &HttpRequest) -> ListParams {
303 let get = |k: &str| req.query_param(k);
304 let mut filters = HashMap::new();
305 for (k, v) in req.query().iter() {
306 if let Some(field) = k.strip_prefix("filter.") {
307 filters.insert(field.to_string(), v.to_owned());
308 }
309 }
310 ListParams {
311 page: get("page").and_then(|p| p.parse().ok()),
312 per_page: get("per_page").and_then(|p| p.parse().ok()),
313 sort: get("sort").map(str::to_owned),
314 order: match get("order") {
315 Some("desc") | Some("DESC") => Some(SortOrder::Desc),
316 Some("asc") | Some("ASC") => Some(SortOrder::Asc),
317 _ => None,
318 },
319 search: get("search").map(str::to_owned),
320 filters,
321 }
322}
323
324fn parse_body(req: &HttpRequest) -> serde_json::Value {
326 let is_json = req
327 .headers
328 .get_ignore_case("content-type")
329 .map(|ct| ct.contains("application/json"))
330 .unwrap_or(false);
331
332 if is_json && let Ok(value) = req.json::<serde_json::Value>() {
333 return value;
334 }
335
336 if let Ok(map) = req.form_map() {
337 let obj: serde_json::Map<String, serde_json::Value> = map
338 .into_iter()
339 .map(|(k, v)| (k, serde_json::Value::String(v)))
340 .collect();
341 return serde_json::Value::Object(obj);
342 }
343
344 serde_json::Value::Object(Default::default())
345}
346
347fn html_response(status: u16, body: String) -> HttpResponse {
348 let mut resp = HttpResponse::html(body);
349 resp.status = status;
350 resp
351}
352
353fn csv_field(value: &str) -> String {
356 if value.contains([',', '"', '\n', '\r']) {
357 format!("\"{}\"", value.replace('"', "\"\""))
358 } else {
359 value.to_string()
360 }
361}
362
363fn render_list_csv(model: &ModelDefinition, rows: &[serde_json::Value]) -> String {
368 let fields = model.display_fields();
369 let mut out = String::new();
370
371 let header: Vec<String> = fields.iter().map(|f| csv_field(&f.label)).collect();
372 out.push_str(&header.join(","));
373 out.push_str("\r\n");
374
375 for row in rows {
376 let cells: Vec<String> = fields
377 .iter()
378 .map(|f| {
379 let raw = row
380 .get(&f.name)
381 .map(data::value_to_plain_string)
382 .unwrap_or_default();
383 csv_field(&raw)
384 })
385 .collect();
386 out.push_str(&cells.join(","));
387 out.push_str("\r\n");
388 }
389
390 out
391}
392
393async fn handle_dashboard(
394 inst: Arc<AdminInstance>,
395 req: HttpRequest,
396) -> Result<HttpResponse, Error> {
397 if !is_authorized(&inst.config, &req) {
398 return Ok(html_response(
399 401,
400 render::render_unauthorized(&inst.config),
401 ));
402 }
403 let mut view = DashboardView::new(&inst);
404 let mut total = 0usize;
406 for summary in &mut view.model_summaries {
407 if let Some(model) = inst.get_model(&summary.name) {
408 let count = inst.data_source.count(model).await;
409 summary.count = count;
410 total += count;
411 }
412 }
413 if let Some(first) = view.stats.first_mut() {
414 first.value = total.to_string();
415 }
416 Ok(HttpResponse::html(render::render_dashboard(
417 &view,
418 &inst.config,
419 )))
420}
421
422async fn handle_list(inst: Arc<AdminInstance>, req: HttpRequest) -> Result<HttpResponse, Error> {
423 let model = match authorize_and_resolve_model(&inst, &req) {
424 Ok(m) => m,
425 Err(resp) => return Ok(*resp),
426 };
427
428 let params = list_params_from_request(&req);
429 let per_page = params.resolve_per_page(&inst.config);
430 let offset = params.resolve_offset(per_page);
431
432 let order_by = if let Some(sort) = ¶ms.sort {
434 vec![format!(
435 "{} {}",
436 sort,
437 params.order.unwrap_or_default().as_sql()
438 )]
439 } else {
440 model.ordering.iter().map(|o| o.as_sql()).collect()
441 };
442
443 if inst.config.enable_export && req.query_param("export") == Some("csv") {
447 let query = DataQuery {
448 offset: 0,
449 limit: 0,
450 order_by,
451 search: params.search.clone(),
452 filters: params.filters.clone(),
453 };
454 let page = inst.data_source.list(&model, &query).await;
455 let csv = render_list_csv(&model, &page.rows);
456 return Ok(HttpResponse::new(200)
457 .with_header(
458 "Content-Type".to_string(),
459 "text/csv; charset=utf-8".to_string(),
460 )
461 .with_header(
462 "Content-Disposition".to_string(),
463 format!("attachment; filename=\"{}.csv\"", model.name),
464 )
465 .with_body(csv.into_bytes()));
466 }
467
468 let query = DataQuery {
469 offset,
470 limit: per_page,
471 order_by,
472 search: params.search.clone(),
473 filters: params.filters.clone(),
474 };
475
476 let page = inst.data_source.list(&model, &query).await;
477 let view = ListView::new(&model, params, per_page).with_json_rows(
478 &model,
479 &inst.config,
480 &page.rows,
481 page.total,
482 );
483 Ok(HttpResponse::html(render::render_list(&view, &inst.config)))
484}
485
486async fn handle_detail(inst: Arc<AdminInstance>, req: HttpRequest) -> Result<HttpResponse, Error> {
487 let model = match authorize_and_resolve_model(&inst, &req) {
488 Ok(m) => m,
489 Err(resp) => return Ok(*resp),
490 };
491 let id = req.param("id").map(str::to_owned).unwrap_or_default();
492
493 match inst.data_source.get(&model, &id).await {
494 Some(record) => {
495 let view = DetailView::new(&model, id).with_data(record);
496 Ok(HttpResponse::html(render::render_detail(
497 &view,
498 &inst.config,
499 )))
500 }
501 None => Ok(html_response(404, render::render_not_found(&inst.config))),
502 }
503}
504
505async fn handle_create_form(
506 inst: Arc<AdminInstance>,
507 req: HttpRequest,
508) -> Result<HttpResponse, Error> {
509 let model = match authorize_and_resolve_model(&inst, &req) {
510 Ok(m) => m,
511 Err(resp) => return Ok(*resp),
512 };
513 let view = CreateView::new(&model);
514 Ok(HttpResponse::html(render::render_create(
515 &view,
516 &inst.config,
517 )))
518}
519
520async fn handle_create_submit(
521 inst: Arc<AdminInstance>,
522 req: HttpRequest,
523) -> Result<HttpResponse, Error> {
524 let model = match authorize_and_resolve_model(&inst, &req) {
525 Ok(m) => m,
526 Err(resp) => return Ok(*resp),
527 };
528 if !model.can_add {
529 return Ok(html_response(
530 403,
531 render::render_unauthorized(&inst.config),
532 ));
533 }
534 let data = parse_body(&req);
535 match inst.data_source.create(&model, data).await {
536 Ok(id) => Ok(HttpResponse::redirect(format!(
537 "{}/{}/{}",
538 inst.config.base_path, model.name, id
539 ))),
540 Err(e) => Ok(html_response(
541 400,
542 render::render_error(&inst.config, &e.to_string()),
543 )),
544 }
545}
546
547async fn handle_edit_form(
548 inst: Arc<AdminInstance>,
549 req: HttpRequest,
550) -> Result<HttpResponse, Error> {
551 let model = match authorize_and_resolve_model(&inst, &req) {
552 Ok(m) => m,
553 Err(resp) => return Ok(*resp),
554 };
555 let id = req.param("id").map(str::to_owned).unwrap_or_default();
556 match inst.data_source.get(&model, &id).await {
557 Some(record) => {
558 let view = EditView::new(&model, id).with_data(record);
559 Ok(HttpResponse::html(render::render_edit(&view, &inst.config)))
560 }
561 None => Ok(html_response(404, render::render_not_found(&inst.config))),
562 }
563}
564
565async fn handle_update_submit(
566 inst: Arc<AdminInstance>,
567 req: HttpRequest,
568) -> Result<HttpResponse, Error> {
569 let model = match authorize_and_resolve_model(&inst, &req) {
570 Ok(m) => m,
571 Err(resp) => return Ok(*resp),
572 };
573 let id = req.param("id").map(str::to_owned).unwrap_or_default();
574 if !model.can_edit {
575 return Ok(html_response(
576 403,
577 render::render_unauthorized(&inst.config),
578 ));
579 }
580 let data = parse_body(&req);
581 match inst.data_source.update(&model, &id, data).await {
582 Ok(()) => Ok(HttpResponse::redirect(format!(
583 "{}/{}/{}",
584 inst.config.base_path, model.name, id
585 ))),
586 Err(e) => Ok(html_response(
587 400,
588 render::render_error(&inst.config, &e.to_string()),
589 )),
590 }
591}
592
593async fn handle_delete(inst: Arc<AdminInstance>, req: HttpRequest) -> Result<HttpResponse, Error> {
594 let model = match authorize_and_resolve_model(&inst, &req) {
595 Ok(m) => m,
596 Err(resp) => return Ok(*resp),
597 };
598 let id = req.param("id").map(str::to_owned).unwrap_or_default();
599 if !model.can_delete {
600 return Ok(html_response(
601 403,
602 render::render_unauthorized(&inst.config),
603 ));
604 }
605 match inst.data_source.delete(&model, &id).await {
606 Ok(()) => Ok(HttpResponse::redirect(format!(
607 "{}/{}",
608 inst.config.base_path, model.name
609 ))),
610 Err(e) => Ok(html_response(
611 404,
612 render::render_error(&inst.config, &e.to_string()),
613 )),
614 }
615}
616
617#[derive(Debug, Clone, Default, Serialize, Deserialize)]
619pub struct ListParams {
620 pub page: Option<usize>,
622 pub per_page: Option<usize>,
624 pub sort: Option<String>,
626 pub order: Option<SortOrder>,
628 pub search: Option<String>,
630 pub filters: HashMap<String, String>,
632}
633
634impl ListParams {
635 pub fn page(&self) -> usize {
637 self.page.unwrap_or(1).max(1)
638 }
639
640 pub fn resolve_per_page(&self, config: &AdminConfig) -> usize {
643 let cap = config.max_items_per_page.max(1);
644 self.per_page.unwrap_or(config.items_per_page).clamp(1, cap)
645 }
646
647 pub fn resolve_offset(&self, per_page: usize) -> usize {
649 (self.page() - 1) * per_page
650 }
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
655pub enum SortOrder {
656 #[default]
657 Asc,
658 Desc,
659}
660
661impl SortOrder {
662 pub fn as_sql(&self) -> &'static str {
664 match self {
665 Self::Asc => "ASC",
666 Self::Desc => "DESC",
667 }
668 }
669
670 pub fn toggle(&self) -> Self {
672 match self {
673 Self::Asc => Self::Desc,
674 Self::Desc => Self::Asc,
675 }
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn test_admin_builder() {
685 let admin = Admin::new()
686 .title("Test Admin")
687 .base_path("/admin")
688 .items_per_page(25)
689 .build();
690
691 assert_eq!(admin.config.title, "Test Admin");
692 assert_eq!(admin.config.base_path, "/admin");
693 assert_eq!(admin.config.items_per_page, 25);
694 }
695
696 #[test]
697 fn test_list_params() {
698 let params = ListParams {
699 page: Some(2),
700 per_page: Some(20),
701 ..Default::default()
702 };
703
704 assert_eq!(params.page(), 2);
705 assert_eq!(params.resolve_per_page(&AdminConfig::default()), 20);
706 assert_eq!(params.resolve_offset(20), 20);
707 }
708
709 #[test]
710 fn test_sort_order() {
711 assert_eq!(SortOrder::Asc.toggle(), SortOrder::Desc);
712 assert_eq!(SortOrder::Desc.toggle(), SortOrder::Asc);
713 }
714
715 use armature_core::HttpRequest;
718
719 fn user_model() -> ModelDefinition {
720 ModelDefinition::builder("user")
721 .id_field()
722 .field(FieldDefinition::new("name", FieldType::String).searchable())
723 .search_fields(["name"])
724 .list_display(["id", "name"])
725 .build()
726 }
727
728 fn req(method: &str, path: &str) -> HttpRequest {
729 HttpRequest::new(method.to_string(), path.to_string())
730 }
731
732 #[tokio::test]
734 async fn routes_are_registered_and_respond() {
735 let admin = Admin::new()
736 .require_auth(false)
737 .register_model(user_model())
738 .build();
739
740 let router = admin.routes();
741 assert!(
742 router.routes.len() >= 6,
743 "expected multiple registered routes, got {}",
744 router.routes.len()
745 );
746
747 for path in ["/admin", "/admin/user", "/admin/user/add"] {
749 let resp = router.route(req("GET", path)).await.unwrap();
750 assert_eq!(resp.status, 200, "route {path} should respond 200");
751 assert!(!resp.body.is_empty(), "route {path} should have a body");
752 }
753 }
754
755 #[tokio::test]
757 async fn stub_data_source_populates_rows() {
758 let ds = Arc::new(InMemoryDataSource::new());
759 ds.seed("user", serde_json::json!({ "id": 1, "name": "Alice" }));
760 ds.seed("user", serde_json::json!({ "id": 2, "name": "Bob" }));
761
762 let admin = Admin::new()
763 .require_auth(false)
764 .data_source(ds.clone())
765 .register_model(user_model())
766 .build();
767
768 let resp = admin
769 .routes()
770 .route(req("GET", "/admin/user"))
771 .await
772 .unwrap();
773 let body = String::from_utf8(resp.body.to_vec()).unwrap();
774 assert!(
775 body.contains("Alice"),
776 "list body should contain seeded rows"
777 );
778 assert!(body.contains("Bob"));
779 }
780
781 #[tokio::test]
783 async fn pagination_respects_per_page_and_max() {
784 let ds = Arc::new(InMemoryDataSource::new());
785 for i in 0..30 {
786 ds.seed(
787 "user",
788 serde_json::json!({ "id": i, "name": format!("u{i}") }),
789 );
790 }
791
792 let admin = Admin::new()
793 .require_auth(false)
794 .items_per_page(10)
795 .max_items_per_page(5)
796 .data_source(ds.clone())
797 .register_model(user_model())
798 .build();
799 let router = admin.routes();
800
801 let resp = router
803 .route(req("GET", "/admin/user?per_page=3"))
804 .await
805 .unwrap();
806 let body = String::from_utf8(resp.body.to_vec()).unwrap();
807 let row_count = body.matches("<tr>").count() - 1; assert_eq!(row_count, 3, "per_page=3 must return 3 rows");
809
810 let resp = router
812 .route(req("GET", "/admin/user?per_page=1000"))
813 .await
814 .unwrap();
815 let body = String::from_utf8(resp.body.to_vec()).unwrap();
816 let row_count = body.matches("<tr>").count() - 1;
817 assert_eq!(
818 row_count, 5,
819 "per_page must be capped at max_items_per_page"
820 );
821 }
822
823 #[tokio::test]
825 async fn require_auth_guards_routes() {
826 let admin = Admin::new()
827 .require_auth(true)
828 .register_model(user_model())
829 .build();
830 let router = admin.routes();
831
832 let resp = router.route(req("GET", "/admin/user")).await.unwrap();
834 assert_eq!(resp.status, 401, "unauthenticated request must be blocked");
835
836 let mut authed = req("GET", "/admin/user");
838 authed.headers.insert("Authorization", "Bearer token");
839 let resp = router.route(authed).await.unwrap();
840 assert_eq!(resp.status, 200, "authenticated request must pass");
841 }
842
843 #[tokio::test]
845 async fn update_and_delete_handlers_work() {
846 let ds = Arc::new(InMemoryDataSource::new());
847 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
848
849 let admin = Admin::new()
850 .require_auth(false)
851 .data_source(ds.clone())
852 .register_model(user_model())
853 .build();
854 let router = admin.routes();
855
856 let mut update = req("POST", "/admin/user/1/edit");
858 update.set_body(b"name=Renamed".to_vec());
859 let resp = router.route(update).await.unwrap();
860 assert!(
861 (300..400).contains(&resp.status),
862 "successful update should redirect, got {}",
863 resp.status
864 );
865 assert_eq!(ds.get(&user_model(), "1").await.unwrap()["name"], "Renamed");
866
867 let resp = router
869 .route(req("POST", "/admin/user/1/delete"))
870 .await
871 .unwrap();
872 assert!((300..400).contains(&resp.status));
873 assert!(ds.get(&user_model(), "1").await.is_none());
874 }
875
876 #[tokio::test]
880 async fn search_form_param_filters_end_to_end() {
881 let ds = Arc::new(InMemoryDataSource::new());
882 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
883 ds.seed("user", serde_json::json!({ "id": "2", "name": "Bob" }));
884
885 let admin = Admin::new()
886 .require_auth(false)
887 .data_source(ds.clone())
888 .register_model(user_model())
889 .build();
890 let router = admin.routes();
891
892 let resp = router.route(req("GET", "/admin/user")).await.unwrap();
895 let body = String::from_utf8(resp.body.to_vec()).unwrap();
896 let form = body
897 .split("admin-search")
898 .nth(1)
899 .expect("search form must be rendered");
900 let after = form
901 .split("name=\"")
902 .nth(1)
903 .expect("input must have a name");
904 let param = &after[..after.find('"').unwrap()];
905 assert_eq!(
906 param, "search",
907 "rendered search input name must match the handler's query key"
908 );
909
910 let path = format!("/admin/user?{param}=Alice");
912 let resp = router.route(req("GET", &path)).await.unwrap();
913 let body = String::from_utf8(resp.body.to_vec()).unwrap();
914 assert!(body.contains("Alice"), "search must keep the matching row");
915 assert!(
916 !body.contains("Bob"),
917 "search must filter out non-matching rows"
918 );
919 }
920
921 #[tokio::test]
924 async fn export_csv_returns_csv_rows() {
925 let ds = Arc::new(InMemoryDataSource::new());
926 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
927 ds.seed("user", serde_json::json!({ "id": "2", "name": "Bob" }));
928
929 let admin = Admin::new()
930 .require_auth(false)
931 .data_source(ds.clone())
932 .register_model(user_model())
933 .build();
934 let router = admin.routes();
935
936 let resp = router
937 .route(req("GET", "/admin/user?export=csv"))
938 .await
939 .unwrap();
940 assert_eq!(resp.status, 200);
941 let ct = resp
942 .headers
943 .get("Content-Type")
944 .cloned()
945 .unwrap_or_default();
946 assert!(ct.contains("text/csv"), "export must be served as CSV");
947 let body = String::from_utf8(resp.body.to_vec()).unwrap();
948 assert!(
949 !body.contains("<table"),
950 "CSV export must not re-render HTML"
951 );
952 assert!(body.contains("Alice"));
954 assert!(body.contains("Bob"));
955 assert!(body.lines().count() >= 3, "header + two data rows");
956
957 let resp = router
959 .route(req("GET", "/admin/user?export=csv&search=Alice"))
960 .await
961 .unwrap();
962 let body = String::from_utf8(resp.body.to_vec()).unwrap();
963 assert!(body.contains("Alice"));
964 assert!(
965 !body.contains("Bob"),
966 "CSV export must honor the search query"
967 );
968 }
969
970 #[tokio::test]
972 async fn edit_form_renders() {
973 let ds = Arc::new(InMemoryDataSource::new());
974 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
975
976 let admin = Admin::new()
977 .require_auth(false)
978 .data_source(ds.clone())
979 .register_model(user_model())
980 .build();
981
982 let resp = admin
983 .routes()
984 .route(req("GET", "/admin/user/1/edit"))
985 .await
986 .unwrap();
987 assert_eq!(resp.status, 200);
988 let body = String::from_utf8(resp.body.to_vec()).unwrap();
989 assert!(body.contains("Alice"), "edit form should prefill values");
990 assert!(body.contains("<form"), "edit form should render a form");
991 }
992}