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").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
301/// Build [`ListParams`] from the request's query string.
302fn 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
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 && 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/// Parameters for list view
618#[derive(Debug, Clone, Default, Serialize, Deserialize)]
619pub struct ListParams {
620    /// Current page (1-indexed)
621    pub page: Option<usize>,
622    /// Items per page
623    pub per_page: Option<usize>,
624    /// Sort field
625    pub sort: Option<String>,
626    /// Sort direction
627    pub order: Option<SortOrder>,
628    /// Search query
629    pub search: Option<String>,
630    /// Filters
631    pub filters: HashMap<String, String>,
632}
633
634impl ListParams {
635    /// Get effective page number
636    pub fn page(&self) -> usize {
637        self.page.unwrap_or(1).max(1)
638    }
639
640    /// Resolve the effective page size, honoring the requested `per_page`, the
641    /// config default (`items_per_page`), and the cap (`max_items_per_page`).
642    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    /// Compute the row offset for a resolved page size.
648    pub fn resolve_offset(&self, per_page: usize) -> usize {
649        (self.page() - 1) * per_page
650    }
651}
652
653/// Sort order
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
655pub enum SortOrder {
656    #[default]
657    Asc,
658    Desc,
659}
660
661impl SortOrder {
662    /// Get SQL representation
663    pub fn as_sql(&self) -> &'static str {
664        match self {
665            Self::Asc => "ASC",
666            Self::Desc => "DESC",
667        }
668    }
669
670    /// Toggle order
671    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    // ---- Router regression tests (fail against the pre-fix code) ----
716
717    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    /// `routes()` must return a real router with registered, responding routes.
733    #[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        // Dashboard, list, and create form all respond with 200 HTML.
748        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    /// A stub data source must populate list rows (was hardcoded `Vec::new()`).
756    #[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    /// Pagination must honor `per_page` and cap at `max_items_per_page`.
782    #[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        // Requesting per_page=3 yields 3 rows.
802        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; // minus header row
808        assert_eq!(row_count, 3, "per_page=3 must return 3 rows");
809
810        // Requesting per_page=1000 is capped at max_items_per_page (5).
811        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    /// `require_auth` must guard routes.
824    #[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        // No Authorization header -> 401.
833        let resp = router.route(req("GET", "/admin/user")).await.unwrap();
834        assert_eq!(resp.status, 401, "unauthenticated request must be blocked");
835
836        // With Authorization header -> 200.
837        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    /// Update and delete handlers must mutate the data source.
844    #[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        // Update via POST form body.
857        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        // Delete via POST.
868        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    /// End-to-end: the search box rendered on the list page must drive filtering
877    /// when its form is submitted. This ties the *rendered form's* param name to
878    /// the handler — the exact seam where `name="q"` silently broke search.
879    #[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        // Render the list page and pull the search input's `name` straight out of
893        // the emitted HTML, exactly as a browser would submit it.
894        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        // Submit that param and assert real filtering occurs.
911        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    /// The Export control must produce real CSV of the current query, not just
922    /// re-render HTML.
923    #[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        // Header row (labels) + both data rows.
953        assert!(body.contains("Alice"));
954        assert!(body.contains("Bob"));
955        assert!(body.lines().count() >= 3, "header + two data rows");
956
957        // Export honors the active search filter.
958        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    /// The edit route must render an EditView (CRUD is wired, not just claimed).
971    #[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}