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").cloned().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_params.get(k).cloned();
304 let mut filters = HashMap::new();
305 for (k, v) in req.query_params.iter() {
306 if let Some(field) = k.strip_prefix("filter.") {
307 filters.insert(field.to_string(), v.clone());
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"),
314 order: match get("order").as_deref() {
315 Some("desc") | Some("DESC") => Some(SortOrder::Desc),
316 Some("asc") | Some("ASC") => Some(SortOrder::Asc),
317 _ => None,
318 },
319 search: get("search"),
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
447 && req.query_params.get("export").map(String::as_str) == Some("csv")
448 {
449 let query = DataQuery {
450 offset: 0,
451 limit: 0,
452 order_by,
453 search: params.search.clone(),
454 filters: params.filters.clone(),
455 };
456 let page = inst.data_source.list(&model, &query).await;
457 let csv = render_list_csv(&model, &page.rows);
458 return Ok(HttpResponse::new(200)
459 .with_header(
460 "Content-Type".to_string(),
461 "text/csv; charset=utf-8".to_string(),
462 )
463 .with_header(
464 "Content-Disposition".to_string(),
465 format!("attachment; filename=\"{}.csv\"", model.name),
466 )
467 .with_body(csv.into_bytes()));
468 }
469
470 let query = DataQuery {
471 offset,
472 limit: per_page,
473 order_by,
474 search: params.search.clone(),
475 filters: params.filters.clone(),
476 };
477
478 let page = inst.data_source.list(&model, &query).await;
479 let view = ListView::new(&model, params, per_page).with_json_rows(
480 &model,
481 &inst.config,
482 &page.rows,
483 page.total,
484 );
485 Ok(HttpResponse::html(render::render_list(&view, &inst.config)))
486}
487
488async fn handle_detail(inst: Arc<AdminInstance>, req: HttpRequest) -> Result<HttpResponse, Error> {
489 let model = match authorize_and_resolve_model(&inst, &req) {
490 Ok(m) => m,
491 Err(resp) => return Ok(*resp),
492 };
493 let id = req.param("id").cloned().unwrap_or_default();
494
495 match inst.data_source.get(&model, &id).await {
496 Some(record) => {
497 let view = DetailView::new(&model, id).with_data(record);
498 Ok(HttpResponse::html(render::render_detail(
499 &view,
500 &inst.config,
501 )))
502 }
503 None => Ok(html_response(404, render::render_not_found(&inst.config))),
504 }
505}
506
507async fn handle_create_form(
508 inst: Arc<AdminInstance>,
509 req: HttpRequest,
510) -> Result<HttpResponse, Error> {
511 let model = match authorize_and_resolve_model(&inst, &req) {
512 Ok(m) => m,
513 Err(resp) => return Ok(*resp),
514 };
515 let view = CreateView::new(&model);
516 Ok(HttpResponse::html(render::render_create(
517 &view,
518 &inst.config,
519 )))
520}
521
522async fn handle_create_submit(
523 inst: Arc<AdminInstance>,
524 req: HttpRequest,
525) -> Result<HttpResponse, Error> {
526 let model = match authorize_and_resolve_model(&inst, &req) {
527 Ok(m) => m,
528 Err(resp) => return Ok(*resp),
529 };
530 if !model.can_add {
531 return Ok(html_response(
532 403,
533 render::render_unauthorized(&inst.config),
534 ));
535 }
536 let data = parse_body(&req);
537 match inst.data_source.create(&model, data).await {
538 Ok(id) => Ok(HttpResponse::redirect(format!(
539 "{}/{}/{}",
540 inst.config.base_path, model.name, id
541 ))),
542 Err(e) => Ok(html_response(
543 400,
544 render::render_error(&inst.config, &e.to_string()),
545 )),
546 }
547}
548
549async fn handle_edit_form(
550 inst: Arc<AdminInstance>,
551 req: HttpRequest,
552) -> Result<HttpResponse, Error> {
553 let model = match authorize_and_resolve_model(&inst, &req) {
554 Ok(m) => m,
555 Err(resp) => return Ok(*resp),
556 };
557 let id = req.param("id").cloned().unwrap_or_default();
558 match inst.data_source.get(&model, &id).await {
559 Some(record) => {
560 let view = EditView::new(&model, id).with_data(record);
561 Ok(HttpResponse::html(render::render_edit(&view, &inst.config)))
562 }
563 None => Ok(html_response(404, render::render_not_found(&inst.config))),
564 }
565}
566
567async fn handle_update_submit(
568 inst: Arc<AdminInstance>,
569 req: HttpRequest,
570) -> Result<HttpResponse, Error> {
571 let model = match authorize_and_resolve_model(&inst, &req) {
572 Ok(m) => m,
573 Err(resp) => return Ok(*resp),
574 };
575 let id = req.param("id").cloned().unwrap_or_default();
576 if !model.can_edit {
577 return Ok(html_response(
578 403,
579 render::render_unauthorized(&inst.config),
580 ));
581 }
582 let data = parse_body(&req);
583 match inst.data_source.update(&model, &id, data).await {
584 Ok(()) => Ok(HttpResponse::redirect(format!(
585 "{}/{}/{}",
586 inst.config.base_path, model.name, id
587 ))),
588 Err(e) => Ok(html_response(
589 400,
590 render::render_error(&inst.config, &e.to_string()),
591 )),
592 }
593}
594
595async fn handle_delete(inst: Arc<AdminInstance>, req: HttpRequest) -> Result<HttpResponse, Error> {
596 let model = match authorize_and_resolve_model(&inst, &req) {
597 Ok(m) => m,
598 Err(resp) => return Ok(*resp),
599 };
600 let id = req.param("id").cloned().unwrap_or_default();
601 if !model.can_delete {
602 return Ok(html_response(
603 403,
604 render::render_unauthorized(&inst.config),
605 ));
606 }
607 match inst.data_source.delete(&model, &id).await {
608 Ok(()) => Ok(HttpResponse::redirect(format!(
609 "{}/{}",
610 inst.config.base_path, model.name
611 ))),
612 Err(e) => Ok(html_response(
613 404,
614 render::render_error(&inst.config, &e.to_string()),
615 )),
616 }
617}
618
619#[derive(Debug, Clone, Default, Serialize, Deserialize)]
621pub struct ListParams {
622 pub page: Option<usize>,
624 pub per_page: Option<usize>,
626 pub sort: Option<String>,
628 pub order: Option<SortOrder>,
630 pub search: Option<String>,
632 pub filters: HashMap<String, String>,
634}
635
636impl ListParams {
637 pub fn page(&self) -> usize {
639 self.page.unwrap_or(1).max(1)
640 }
641
642 pub fn resolve_per_page(&self, config: &AdminConfig) -> usize {
645 let cap = config.max_items_per_page.max(1);
646 self.per_page.unwrap_or(config.items_per_page).clamp(1, cap)
647 }
648
649 pub fn resolve_offset(&self, per_page: usize) -> usize {
651 (self.page() - 1) * per_page
652 }
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
657pub enum SortOrder {
658 #[default]
659 Asc,
660 Desc,
661}
662
663impl SortOrder {
664 pub fn as_sql(&self) -> &'static str {
666 match self {
667 Self::Asc => "ASC",
668 Self::Desc => "DESC",
669 }
670 }
671
672 pub fn toggle(&self) -> Self {
674 match self {
675 Self::Asc => Self::Desc,
676 Self::Desc => Self::Asc,
677 }
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[test]
686 fn test_admin_builder() {
687 let admin = Admin::new()
688 .title("Test Admin")
689 .base_path("/admin")
690 .items_per_page(25)
691 .build();
692
693 assert_eq!(admin.config.title, "Test Admin");
694 assert_eq!(admin.config.base_path, "/admin");
695 assert_eq!(admin.config.items_per_page, 25);
696 }
697
698 #[test]
699 fn test_list_params() {
700 let params = ListParams {
701 page: Some(2),
702 per_page: Some(20),
703 ..Default::default()
704 };
705
706 assert_eq!(params.page(), 2);
707 assert_eq!(params.resolve_per_page(&AdminConfig::default()), 20);
708 assert_eq!(params.resolve_offset(20), 20);
709 }
710
711 #[test]
712 fn test_sort_order() {
713 assert_eq!(SortOrder::Asc.toggle(), SortOrder::Desc);
714 assert_eq!(SortOrder::Desc.toggle(), SortOrder::Asc);
715 }
716
717 use armature_core::HttpRequest;
720
721 fn user_model() -> ModelDefinition {
722 ModelDefinition::builder("user")
723 .id_field()
724 .field(FieldDefinition::new("name", FieldType::String).searchable())
725 .search_fields(["name"])
726 .list_display(["id", "name"])
727 .build()
728 }
729
730 fn req(method: &str, path: &str) -> HttpRequest {
731 HttpRequest::new(method.to_string(), path.to_string())
732 }
733
734 #[tokio::test]
736 async fn routes_are_registered_and_respond() {
737 let admin = Admin::new()
738 .require_auth(false)
739 .register_model(user_model())
740 .build();
741
742 let router = admin.routes();
743 assert!(
744 router.routes.len() >= 6,
745 "expected multiple registered routes, got {}",
746 router.routes.len()
747 );
748
749 for path in ["/admin", "/admin/user", "/admin/user/add"] {
751 let resp = router.route(req("GET", path)).await.unwrap();
752 assert_eq!(resp.status, 200, "route {path} should respond 200");
753 assert!(!resp.body.is_empty(), "route {path} should have a body");
754 }
755 }
756
757 #[tokio::test]
759 async fn stub_data_source_populates_rows() {
760 let ds = Arc::new(InMemoryDataSource::new());
761 ds.seed("user", serde_json::json!({ "id": 1, "name": "Alice" }));
762 ds.seed("user", serde_json::json!({ "id": 2, "name": "Bob" }));
763
764 let admin = Admin::new()
765 .require_auth(false)
766 .data_source(ds.clone())
767 .register_model(user_model())
768 .build();
769
770 let resp = admin
771 .routes()
772 .route(req("GET", "/admin/user"))
773 .await
774 .unwrap();
775 let body = String::from_utf8(resp.body).unwrap();
776 assert!(
777 body.contains("Alice"),
778 "list body should contain seeded rows"
779 );
780 assert!(body.contains("Bob"));
781 }
782
783 #[tokio::test]
785 async fn pagination_respects_per_page_and_max() {
786 let ds = Arc::new(InMemoryDataSource::new());
787 for i in 0..30 {
788 ds.seed(
789 "user",
790 serde_json::json!({ "id": i, "name": format!("u{i}") }),
791 );
792 }
793
794 let admin = Admin::new()
795 .require_auth(false)
796 .items_per_page(10)
797 .max_items_per_page(5)
798 .data_source(ds.clone())
799 .register_model(user_model())
800 .build();
801 let router = admin.routes();
802
803 let resp = router
805 .route(req("GET", "/admin/user?per_page=3"))
806 .await
807 .unwrap();
808 let body = String::from_utf8(resp.body).unwrap();
809 let row_count = body.matches("<tr>").count() - 1; assert_eq!(row_count, 3, "per_page=3 must return 3 rows");
811
812 let resp = router
814 .route(req("GET", "/admin/user?per_page=1000"))
815 .await
816 .unwrap();
817 let body = String::from_utf8(resp.body).unwrap();
818 let row_count = body.matches("<tr>").count() - 1;
819 assert_eq!(
820 row_count, 5,
821 "per_page must be capped at max_items_per_page"
822 );
823 }
824
825 #[tokio::test]
827 async fn require_auth_guards_routes() {
828 let admin = Admin::new()
829 .require_auth(true)
830 .register_model(user_model())
831 .build();
832 let router = admin.routes();
833
834 let resp = router.route(req("GET", "/admin/user")).await.unwrap();
836 assert_eq!(resp.status, 401, "unauthenticated request must be blocked");
837
838 let mut authed = req("GET", "/admin/user");
840 authed.headers.insert("Authorization", "Bearer token");
841 let resp = router.route(authed).await.unwrap();
842 assert_eq!(resp.status, 200, "authenticated request must pass");
843 }
844
845 #[tokio::test]
847 async fn update_and_delete_handlers_work() {
848 let ds = Arc::new(InMemoryDataSource::new());
849 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
850
851 let admin = Admin::new()
852 .require_auth(false)
853 .data_source(ds.clone())
854 .register_model(user_model())
855 .build();
856 let router = admin.routes();
857
858 let mut update = req("POST", "/admin/user/1/edit");
860 update.set_body(b"name=Renamed".to_vec());
861 let resp = router.route(update).await.unwrap();
862 assert!(
863 (300..400).contains(&resp.status),
864 "successful update should redirect, got {}",
865 resp.status
866 );
867 assert_eq!(ds.get(&user_model(), "1").await.unwrap()["name"], "Renamed");
868
869 let resp = router
871 .route(req("POST", "/admin/user/1/delete"))
872 .await
873 .unwrap();
874 assert!((300..400).contains(&resp.status));
875 assert!(ds.get(&user_model(), "1").await.is_none());
876 }
877
878 #[tokio::test]
882 async fn search_form_param_filters_end_to_end() {
883 let ds = Arc::new(InMemoryDataSource::new());
884 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
885 ds.seed("user", serde_json::json!({ "id": "2", "name": "Bob" }));
886
887 let admin = Admin::new()
888 .require_auth(false)
889 .data_source(ds.clone())
890 .register_model(user_model())
891 .build();
892 let router = admin.routes();
893
894 let resp = router.route(req("GET", "/admin/user")).await.unwrap();
897 let body = String::from_utf8(resp.body).unwrap();
898 let form = body
899 .split("admin-search")
900 .nth(1)
901 .expect("search form must be rendered");
902 let after = form
903 .split("name=\"")
904 .nth(1)
905 .expect("input must have a name");
906 let param = &after[..after.find('"').unwrap()];
907 assert_eq!(
908 param, "search",
909 "rendered search input name must match the handler's query key"
910 );
911
912 let path = format!("/admin/user?{param}=Alice");
914 let resp = router.route(req("GET", &path)).await.unwrap();
915 let body = String::from_utf8(resp.body).unwrap();
916 assert!(body.contains("Alice"), "search must keep the matching row");
917 assert!(
918 !body.contains("Bob"),
919 "search must filter out non-matching rows"
920 );
921 }
922
923 #[tokio::test]
926 async fn export_csv_returns_csv_rows() {
927 let ds = Arc::new(InMemoryDataSource::new());
928 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
929 ds.seed("user", serde_json::json!({ "id": "2", "name": "Bob" }));
930
931 let admin = Admin::new()
932 .require_auth(false)
933 .data_source(ds.clone())
934 .register_model(user_model())
935 .build();
936 let router = admin.routes();
937
938 let resp = router
939 .route(req("GET", "/admin/user?export=csv"))
940 .await
941 .unwrap();
942 assert_eq!(resp.status, 200);
943 let ct = resp
944 .headers
945 .get("Content-Type")
946 .cloned()
947 .unwrap_or_default();
948 assert!(ct.contains("text/csv"), "export must be served as CSV");
949 let body = String::from_utf8(resp.body).unwrap();
950 assert!(
951 !body.contains("<table"),
952 "CSV export must not re-render HTML"
953 );
954 assert!(body.contains("Alice"));
956 assert!(body.contains("Bob"));
957 assert!(body.lines().count() >= 3, "header + two data rows");
958
959 let resp = router
961 .route(req("GET", "/admin/user?export=csv&search=Alice"))
962 .await
963 .unwrap();
964 let body = String::from_utf8(resp.body).unwrap();
965 assert!(body.contains("Alice"));
966 assert!(
967 !body.contains("Bob"),
968 "CSV export must honor the search query"
969 );
970 }
971
972 #[tokio::test]
974 async fn edit_form_renders() {
975 let ds = Arc::new(InMemoryDataSource::new());
976 ds.seed("user", serde_json::json!({ "id": "1", "name": "Alice" }));
977
978 let admin = Admin::new()
979 .require_auth(false)
980 .data_source(ds.clone())
981 .register_model(user_model())
982 .build();
983
984 let resp = admin
985 .routes()
986 .route(req("GET", "/admin/user/1/edit"))
987 .await
988 .unwrap();
989 assert_eq!(resp.status, 200);
990 let body = String::from_utf8(resp.body).unwrap();
991 assert!(body.contains("Alice"), "edit form should prefill values");
992 assert!(body.contains("<form"), "edit form should render a form");
993 }
994}