Skip to main content

armature_admin/
lib.rs

1//! Admin Dashboard Generator for Armature Framework
2//!
3//! Auto-generates a complete CRUD admin interface from your models,
4//! similar to Django Admin or Rails Admin.
5//!
6//! ## Overview
7//!
8//! ```text
9//! ┌─────────────────────────────────────────────────────────────────┐
10//! │                    Admin Dashboard                               │
11//! │                                                                  │
12//! │  ┌──────────────────────────────────────────────────────────┐  │
13//! │  │  Navigation    │  Content Area                           │  │
14//! │  │  ───────────   │  ─────────────                          │  │
15//! │  │  Dashboard     │  ┌────────────────────────────────────┐ │  │
16//! │  │  Users         │  │  Users List                        │ │  │
17//! │  │  Products      │  │  ─────────────────────────────────  │ │  │
18//! │  │  Orders        │  │  [Search] [Filter] [+Add]          │ │  │
19//! │  │  Settings      │  │  ┌────┬────────┬────────┬───────┐  │ │  │
20//! │  │                │  │  │ ID │ Name   │ Email  │ Actions│  │ │  │
21//! │  │                │  │  ├────┼────────┼────────┼───────┤  │ │  │
22//! │  │                │  │  │ 1  │ Alice  │ a@...  │ ✏️ 🗑️ │  │ │  │
23//! │  │                │  │  │ 2  │ Bob    │ b@...  │ ✏️ 🗑️ │  │ │  │
24//! │  │                │  │  └────┴────────┴────────┴───────┘  │ │  │
25//! │  │                │  │  [◀ Prev] Page 1 of 10 [Next ▶]   │ │  │
26//! │  │                │  └────────────────────────────────────┘ │  │
27//! │  └──────────────────────────────────────────────────────────┘  │
28//! └─────────────────────────────────────────────────────────────────┘
29//! ```
30//!
31//! ## Quick Start
32//!
33//! ```rust,ignore
34//! use armature_admin::{Admin, AdminModel, Field};
35//!
36//! #[derive(AdminModel)]
37//! #[admin(list_display = ["id", "name", "email"])]
38//! #[admin(search_fields = ["name", "email"])]
39//! struct User {
40//!     #[admin(primary_key)]
41//!     id: i64,
42//!     #[admin(required)]
43//!     name: String,
44//!     #[admin(widget = "email")]
45//!     email: String,
46//!     #[admin(readonly)]
47//!     created_at: DateTime<Utc>,
48//! }
49//!
50//! let admin = Admin::new()
51//!     .title("My Admin")
52//!     .register::<User>()
53//!     .build();
54//!
55//! // Mount at /admin
56//! app.mount("/admin", admin.routes());
57//! ```
58
59pub 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
85/// Admin instance builder
86pub struct Admin {
87    /// Admin configuration
88    config: AdminConfig,
89    /// Model registry
90    registry: ModelRegistry,
91    /// Backing data source
92    data_source: Arc<dyn DataSource>,
93}
94
95impl Admin {
96    /// Create a new admin builder
97    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    /// Set the admin title
106    pub fn title(mut self, title: impl Into<String>) -> Self {
107        self.config.title = title.into();
108        self
109    }
110
111    /// Set the base URL path
112    pub fn base_path(mut self, path: impl Into<String>) -> Self {
113        self.config.base_path = path.into();
114        self
115    }
116
117    /// Set the theme
118    pub fn theme(mut self, theme: Theme) -> Self {
119        self.config.theme = theme;
120        self
121    }
122
123    /// Set items per page
124    pub fn items_per_page(mut self, count: usize) -> Self {
125        self.config.items_per_page = count;
126        self
127    }
128
129    /// Set the maximum items-per-page a client may request
130    pub fn max_items_per_page(mut self, count: usize) -> Self {
131        self.config.max_items_per_page = count;
132        self
133    }
134
135    /// Enable/disable authentication
136    pub fn require_auth(mut self, required: bool) -> Self {
137        self.config.require_auth = required;
138        self
139    }
140
141    /// Set the backing data source (defaults to an in-memory store)
142    pub fn data_source(mut self, source: Arc<dyn DataSource>) -> Self {
143        self.data_source = source;
144        self
145    }
146
147    /// Register a model with the admin
148    pub fn register_model(mut self, model: ModelDefinition) -> Self {
149        self.registry.register(model);
150        self
151    }
152
153    /// Build the admin instance
154    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/// Built admin instance
170#[derive(Clone)]
171pub struct AdminInstance {
172    /// Configuration
173    pub config: Arc<AdminConfig>,
174    /// Model registry
175    pub registry: Arc<ModelRegistry>,
176    /// Backing data source
177    pub data_source: Arc<dyn DataSource>,
178}
179
180impl AdminInstance {
181    /// Get the configuration
182    pub fn config(&self) -> &AdminConfig {
183        &self.config
184    }
185
186    /// Get the model registry
187    pub fn registry(&self) -> &ModelRegistry {
188        &self.registry
189    }
190
191    /// Get the backing data source
192    pub fn data_source(&self) -> &Arc<dyn DataSource> {
193        &self.data_source
194    }
195
196    /// Build a mountable [`armature_core::Router`] for the admin interface.
197    ///
198    /// Every route renders HTML through the view structs and is backed by the
199    /// configured [`DataSource`]. When `require_auth` is set, mutating and
200    /// browsing routes require an `Authorization` header.
201    ///
202    /// Registered routes (relative to `base_path`, default `/admin`):
203    /// - `GET  {base}` — dashboard
204    /// - `GET  {base}/:model` — list
205    /// - `GET  {base}/:model/add` + `POST {base}/:model/add` — create
206    /// - `GET  {base}/:model/:id` — detail
207    /// - `GET  {base}/:model/:id/edit` + `POST {base}/:model/:id/edit` — update
208    /// - `POST {base}/:model/:id/delete` (also `DELETE {base}/:model/:id`) — delete
209    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        // Static `/add` and `/:id/edit` must precede the `:id` param routes so
227        // they are matched first (the router returns the first matching route).
228        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    /// Get a model definition by name
240    pub fn get_model(&self, name: &str) -> Option<&ModelDefinition> {
241        self.registry.get(name)
242    }
243
244    /// List all registered models
245    pub fn models(&self) -> Vec<&ModelDefinition> {
246        self.registry.all()
247    }
248}
249
250/// Returns `true` if the request may proceed given the auth policy.
251///
252/// # This is a PRESENCE check, not authentication
253///
254/// When `require_auth` is disabled every request passes. When it is enabled this
255/// function only checks that a **non-empty `Authorization` header is present** —
256/// it does **not** parse, validate, or verify the credential in any way. Any
257/// non-empty value (e.g. `Authorization: x`) is accepted.
258///
259/// This is deliberate: the admin router does not implement authentication. Real
260/// verification (validating a session, bearer token, or signature and rejecting
261/// forged/expired credentials) MUST be performed by upstream authentication
262/// middleware mounted ahead of these routes. Enabling `require_auth(true)`
263/// without such middleware provides **no security** — it merely rejects requests
264/// that omit the header entirely.
265fn 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
275/// Enforce the auth policy and resolve the `:model` path param to its
276/// [`ModelDefinition`], centralizing the identical 401/404 handling that opens
277/// every model-scoped handler.
278///
279/// Returns the resolved model on success, or the ready-to-return error response
280/// (401 when unauthorized, 404 when the model is unknown).
281fn 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
301/// Build [`ListParams`] from the request's query string.
302fn 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
324/// Parse a submitted form (or JSON body) into a JSON object for the data source.
325fn 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
353/// Quote a single CSV field per RFC 4180 (double internal quotes; wrap in quotes
354/// when the value contains a comma, quote, or newline).
355fn csv_field(value: &str) -> String {
356    if value.contains([',', '"', '\n', '\r']) {
357        format!("\"{}\"", value.replace('"', "\"\""))
358    } else {
359        value.to_string()
360    }
361}
362
363/// Render the model's `list_display` columns for the given rows as CSV text.
364///
365/// The header row uses the display fields' labels; each data row emits the
366/// plain (unescaped) value of each display field.
367fn 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    // Populate live counts from the data source.
405    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    // Ordering clauses, honoring the sort param else the model default.
433    let order_by = if let Some(sort) = &params.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    // CSV export of the current query (honoring search/filters/ordering). A
444    // `limit` of 0 tells the data source to return every matching row rather
445    // than a single page.
446    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/// Parameters for list view
620#[derive(Debug, Clone, Default, Serialize, Deserialize)]
621pub struct ListParams {
622    /// Current page (1-indexed)
623    pub page: Option<usize>,
624    /// Items per page
625    pub per_page: Option<usize>,
626    /// Sort field
627    pub sort: Option<String>,
628    /// Sort direction
629    pub order: Option<SortOrder>,
630    /// Search query
631    pub search: Option<String>,
632    /// Filters
633    pub filters: HashMap<String, String>,
634}
635
636impl ListParams {
637    /// Get effective page number
638    pub fn page(&self) -> usize {
639        self.page.unwrap_or(1).max(1)
640    }
641
642    /// Resolve the effective page size, honoring the requested `per_page`, the
643    /// config default (`items_per_page`), and the cap (`max_items_per_page`).
644    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    /// Compute the row offset for a resolved page size.
650    pub fn resolve_offset(&self, per_page: usize) -> usize {
651        (self.page() - 1) * per_page
652    }
653}
654
655/// Sort order
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
657pub enum SortOrder {
658    #[default]
659    Asc,
660    Desc,
661}
662
663impl SortOrder {
664    /// Get SQL representation
665    pub fn as_sql(&self) -> &'static str {
666        match self {
667            Self::Asc => "ASC",
668            Self::Desc => "DESC",
669        }
670    }
671
672    /// Toggle order
673    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    // ---- Router regression tests (fail against the pre-fix code) ----
718
719    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    /// `routes()` must return a real router with registered, responding routes.
735    #[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        // Dashboard, list, and create form all respond with 200 HTML.
750        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    /// A stub data source must populate list rows (was hardcoded `Vec::new()`).
758    #[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    /// Pagination must honor `per_page` and cap at `max_items_per_page`.
784    #[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        // Requesting per_page=3 yields 3 rows.
804        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; // minus header row
810        assert_eq!(row_count, 3, "per_page=3 must return 3 rows");
811
812        // Requesting per_page=1000 is capped at max_items_per_page (5).
813        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    /// `require_auth` must guard routes.
826    #[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        // No Authorization header -> 401.
835        let resp = router.route(req("GET", "/admin/user")).await.unwrap();
836        assert_eq!(resp.status, 401, "unauthenticated request must be blocked");
837
838        // With Authorization header -> 200.
839        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    /// Update and delete handlers must mutate the data source.
846    #[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        // Update via POST form body.
859        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        // Delete via POST.
870        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    /// End-to-end: the search box rendered on the list page must drive filtering
879    /// when its form is submitted. This ties the *rendered form's* param name to
880    /// the handler — the exact seam where `name="q"` silently broke search.
881    #[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        // Render the list page and pull the search input's `name` straight out of
895        // the emitted HTML, exactly as a browser would submit it.
896        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        // Submit that param and assert real filtering occurs.
913        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    /// The Export control must produce real CSV of the current query, not just
924    /// re-render HTML.
925    #[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        // Header row (labels) + both data rows.
955        assert!(body.contains("Alice"));
956        assert!(body.contains("Bob"));
957        assert!(body.lines().count() >= 3, "header + two data rows");
958
959        // Export honors the active search filter.
960        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    /// The edit route must render an EditView (CRUD is wired, not just claimed).
973    #[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}