Skip to main content

laterite_admin/settings/
mod.rs

1//! Descriptor-driven settings screens, and the settings store behind them.
2//!
3//! A module registers a [`SettingsItem`] for each settings model it wants an
4//! operator to edit: its storage `code` (the [`store::SettingsModel`] `CODE`), a
5//! `category` it groups under, and the fields to render. The framework mounts one
6//! index that lists every registered item grouped by category, and one generic
7//! form per item that reads and writes the model's JSON value through the
8//! [`store`]. No per-model controller is needed, exactly as a single settings
9//! controller serves every settings model.
10//!
11//! Values are stored as one JSON object per code. Field names are JSON keys, not
12//! SQL identifiers, and the value is written through a parameterized upsert, so
13//! nothing here builds SQL from user input.
14
15pub mod brand;
16pub mod migrations;
17pub mod store;
18
19pub use brand::BrandSetting;
20pub use migrations::{migrations, MODULE_ID};
21pub use store::{get, load, save, set, SettingsError, SettingsModel};
22
23/// The `laterite.settings` module: the framework's settings store table.
24pub struct SettingsModule;
25
26impl laterite_core::Module for SettingsModule {
27    fn id(&self) -> laterite_core::ModuleId {
28        laterite_core::ModuleId::new(MODULE_ID)
29    }
30    fn migrations(&self) -> laterite_core::MigrationSet {
31        migrations()
32    }
33}
34
35use std::collections::HashMap;
36
37use askama::Template;
38use axum::response::{IntoResponse, Redirect, Response};
39use laterite_core::validation::Mode;
40use laterite_core::{t, Text, Translator};
41use serde::Serialize;
42use serde_json::{Map, Value};
43
44use crate::{render, render_error, AdminState};
45
46/// One editable field of a settings model.
47///
48/// A settings screen renders [`FormField`](crate::form::FormField)s, the same
49/// descriptors a list or form screen uses: the module declares which fields its
50/// settings model has, and the framework renders and stores them through the
51/// field-type registry. There is no settings-specific field vocabulary.
52pub use crate::form::FormField;
53
54/// A settings model surfaced in the admin: a storage `code`, a `category` and
55/// `order` that place it in the index, and the fields to edit.
56#[derive(Debug, Clone, Serialize)]
57#[non_exhaustive]
58pub struct SettingsItem {
59    /// Storage key. Matches the model's `SettingsModel::CODE`.
60    ///
61    /// This is a storage key and never a URL. It is dotted, like the module
62    /// identity it derives from, and a dotted identifier in a path is how these
63    /// screens spent months at `/settings/rainmill.discovery.robots`. Where the
64    /// screen mounts is [`SettingsItem::route`], resolved at boot through the
65    /// module's namespace like every other contributed screen.
66    pub code: String,
67    /// The item's label, description, and category heading, localized at render.
68    pub label: Text,
69    pub description: Text,
70    /// Group heading in the index.
71    pub category: Text,
72    /// Weight within the category (lower sorts first).
73    pub order: i32,
74    /// Icon name shown beside the item in the context sidebar (a Lucide name
75    /// such as `users` or `shield`). `None` falls back to a generic glyph.
76    pub icon: Option<String>,
77    /// Permission required to edit, enforced by middleware. `None` means any
78    /// authenticated operator.
79    pub permission: Option<String>,
80    /// When set, the item links to this route (e.g. a resource list) instead of
81    /// its settings form. Used to place list/form screens (like Administrators)
82    /// in the settings menu rather than the main menu.
83    pub link: Option<String>,
84    pub fields: Vec<FormField>,
85    /// Where this screen mounts, relative to the admin root, resolved at boot
86    /// from the contributing module's namespace. `None` until then.
87    pub(crate) route: Option<String>,
88}
89
90impl SettingsItem {
91    /// A settings screen for `code` (the model's `SettingsModel::CODE`), titled
92    /// `label`, editing `fields`.
93    ///
94    /// Everything else takes a default and is opted into through a builder
95    /// method, so a field added later costs existing callers nothing.
96    pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
97        Self {
98            code: code.into(),
99            label: label.into(),
100            description: Text::from(""),
101            category: Text::from(""),
102            order: 0,
103            icon: None,
104            permission: None,
105            link: None,
106            fields,
107            route: None,
108        }
109    }
110
111    /// The sentence under the item's name in the index and sidebar.
112    pub fn description(mut self, text: impl Into<Text>) -> Self {
113        self.description = text.into();
114        self
115    }
116
117    /// The group heading this item sorts under.
118    pub fn category(mut self, text: impl Into<Text>) -> Self {
119        self.category = text.into();
120        self
121    }
122
123    /// Weight within the category; lower sorts first.
124    pub fn order(mut self, order: i32) -> Self {
125        self.order = order;
126        self
127    }
128
129    /// A Lucide icon name shown beside the item.
130    pub fn icon(mut self, icon: impl Into<String>) -> Self {
131        self.icon = Some(icon.into());
132        self
133    }
134
135    /// The permission required to edit, enforced by middleware.
136    pub fn permission(mut self, permission: impl Into<String>) -> Self {
137        self.permission = Some(permission.into());
138        self
139    }
140
141    /// Point the item at an existing route instead of its own settings form, so
142    /// a list screen can sit in the settings menu.
143    pub fn link(mut self, link: impl Into<String>) -> Self {
144        self.link = Some(link.into());
145        self
146    }
147
148    /// Records where this screen mounts. Called once at boot, after the
149    /// contributing module's namespace has been resolved.
150    pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
151        self.route = Some(route.into());
152    }
153
154    /// Mounts this screen at a fixed path rather than a module namespace.
155    ///
156    /// For the panel's own settings, which belong to no vendor: they configure
157    /// the admin itself, so they sit under `/settings` with a plain name
158    /// (`/settings/branding`) rather than carrying `laterite` through a URL an
159    /// operator reads. A module's settings live in the module's namespace.
160    pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
161        self.route = Some(route.into());
162        self
163    }
164
165    /// The item's own screen, relative to the admin root, ignoring any `link`.
166    ///
167    /// Before boot resolves it, this falls back to a path under `/settings`
168    /// built from the code's segments rather than the dotted code itself, so
169    /// even the fallback is a URL rather than an identifier. See
170    /// [`SettingsItem::at`] for why the panel's own settings pin theirs.
171    pub(crate) fn route(&self) -> String {
172        match &self.route {
173            Some(route) => route.clone(),
174            None => format!("/settings/{}", self.code.replace('.', "/")),
175        }
176    }
177
178    /// Where this item leads, resolved under the admin mount (`admin_path`): its
179    /// `link` target if set, else its own settings screen. Both are authored
180    /// relative to the admin root, so this prepends the mount.
181    pub fn path(&self, admin_path: &str) -> String {
182        match &self.link {
183            Some(link) => format!("{admin_path}{link}"),
184            None => format!("{admin_path}{}", self.route()),
185        }
186    }
187}
188
189/// Renders the settings index: a prompt to pick a section. The context sidebar
190/// itself is resolved by the auth guard and rendered by the shell.
191pub(crate) fn index(shell: crate::Shell) -> Response {
192    render(SettingsIndexTemplate { shell })
193}
194
195/// Renders the edit form for one item, populated from its stored value. The
196/// context sidebar (with this item active) comes from the shell.
197pub(crate) async fn edit_form(
198    state: &AdminState,
199    item: &SettingsItem,
200    shell: crate::Shell,
201) -> Response {
202    let mut stored = match store::get(&state.db, &item.code).await {
203        Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
204        Err(_) => return render_error(),
205    };
206    // Prefill unset fields from config so they show the current effective value
207    // rather than opening blank. Display only; nothing is written.
208    prefill_from_config(item, &mut stored, &state.app_name);
209    render(build(
210        item,
211        &state.field_types,
212        state.overrides.as_ref(),
213        None,
214        &stored,
215        &shell,
216    ))
217}
218
219/// Persists submitted values as the item's JSON object, then returns to the index.
220pub(crate) async fn update(
221    state: &AdminState,
222    item: &SettingsItem,
223    data: HashMap<String, String>,
224    shell: crate::Shell,
225    session: &crate::session::SessionHandle,
226    user: &laterite_auth::AuthenticatedUser,
227) -> Response {
228    // A field type that refuses its input refuses the save: the screen says so
229    // rather than storing a value every later read has to defend against.
230    let value = match collect(item, &state.field_types, &data) {
231        Ok(value) => value,
232        Err(message) => {
233            let stored = store::get(&state.db, &item.code)
234                .await
235                .ok()
236                .flatten()
237                .unwrap_or(Value::Null);
238            return render(build(
239                item,
240                &state.field_types,
241                state.overrides.as_ref(),
242                Some(message),
243                &stored,
244                &shell,
245            ));
246        }
247    };
248    match store::set(&state.db, &item.code, &value).await {
249        Ok(()) => {
250            // The brand is cached for display; a save to it must invalidate the
251            // cache so the next page reflects the new name.
252            if item.code == brand::BrandSetting::CODE {
253                state.invalidate_brand();
254            }
255            // The stored value can hold secrets, so the audit records which
256            // settings model changed, not the new contents.
257            crate::audit::record(
258                state,
259                user,
260                "backend.settings.update",
261                Some("settings"),
262                Some(item.code.as_str()),
263                None,
264            )
265            .await;
266            session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
267            Redirect::to(&format!("{}/settings", state.admin_path)).into_response()
268        }
269        Err(_) => render(build(
270            item,
271            &state.field_types,
272            state.overrides.as_ref(),
273            Some(t!("Could not save. Please try again.")),
274            &value,
275            &shell,
276        )),
277    }
278}
279
280/// Builds the JSON object to store from the submitted form data, typing each
281/// field through its field type.
282///
283/// The same `to_attr` a descriptor form writes through, so a switch stores a
284/// bool, a repeater stores an array of objects, and a module's own field type
285/// behaves identically here and on a form. A type that refuses its input refuses
286/// the save, naming the field.
287fn collect(
288    item: &SettingsItem,
289    types: &crate::field::FieldRegistry,
290    data: &HashMap<String, String>,
291) -> Result<Value, Text> {
292    let mut object = Map::new();
293    for field in &item.fields {
294        let Some(field_type) = types.get(&field.field_type) else {
295            return Err(t!("This screen uses a field type that is not registered."));
296        };
297        let opts = field_type
298            .resolve_options(&field.options, types)
299            .map_err(|_| t!("This screen has a malformed field."))?;
300        let submitted = crate::field::SubmittedField::new(&field.name, data);
301        // Settings always write every declared key: a settings blob has no
302        // previous row to fall back to the way a table column does.
303        let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
304            Ok(Some(value)) => attr_to_json(value),
305            Ok(None) => Value::String(String::new()),
306            Err(_) => return Err(t!("Check the values and try again.")),
307        };
308        object.insert(field.name.clone(), value);
309    }
310    Ok(Value::Object(object))
311}
312
313/// A stored attribute as the JSON a settings blob holds.
314fn attr_to_json(value: laterite_core::AttrValue) -> Value {
315    use laterite_core::AttrValue;
316    match value {
317        AttrValue::Null => Value::String(String::new()),
318        AttrValue::Bool(b) => Value::Bool(b),
319        AttrValue::Int(i) => Value::from(i),
320        AttrValue::Float(f) => Value::from(f),
321        AttrValue::Json(j) => j,
322        other => Value::String(other.to_text().unwrap_or_default()),
323    }
324}
325
326/// Builds the context-sidebar groups: items grouped by category and ordered
327/// deterministically, with `active_code` (if any) marked. Categories sort by
328/// their lowest item `order`, then name; items by `order`, then label. This is
329/// the simple-weight stage; relative-anchor ordering with an operator override
330/// is a later refinement.
331pub(crate) fn sidebar_groups(
332    items: &[SettingsItem],
333    admin_path: &str,
334    active_code: Option<&str>,
335    tr: &Translator,
336) -> Vec<CategoryView> {
337    // Group by the localized category heading (a shared source localizes identically).
338    let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
339    for item in items {
340        by_category
341            .entry(tr.t(&item.category))
342            .or_default()
343            .push(item);
344    }
345    let mut groups: Vec<CategoryView> = by_category
346        .into_iter()
347        .map(|(category, mut items)| {
348            // Order by weight, then the label's source for a stable tie-break.
349            items.sort_by(|a, b| {
350                a.order
351                    .cmp(&b.order)
352                    .then_with(|| a.label.source().cmp(b.label.source()))
353            });
354            CategoryView {
355                min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
356                name: category,
357                items: items
358                    .iter()
359                    .map(|i| ItemView {
360                        label: tr.t(&i.label),
361                        description: tr.t(&i.description),
362                        path: i.path(admin_path),
363                        icon: crate::icons::svg(i.icon.as_deref()),
364                        active: active_code == Some(i.code.as_str()),
365                    })
366                    .collect(),
367            }
368        })
369        .collect();
370    groups.sort_by(|a, b| {
371        a.min_order
372            .cmp(&b.min_order)
373            .then_with(|| a.name.cmp(&b.name))
374    });
375    groups
376}
377
378/// Prefills unset display fields from configuration before the form renders, so
379/// a field shows the current effective value rather than opening blank. The
380/// brand's application name prefills from the configured `app.name` when no brand
381/// setting is saved. This is display only: it writes nothing, so a later config
382/// change still propagates (persisting it would freeze the value). It is not a
383/// database seeder; that is a separate, deferred capability.
384fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
385    if item.code != brand::BrandSetting::CODE {
386        return;
387    }
388    if let Value::Object(map) = stored {
389        let blank = map
390            .get("app_name")
391            .and_then(Value::as_str)
392            .unwrap_or("")
393            .trim()
394            .is_empty();
395        if blank {
396            map.insert("app_name".to_string(), Value::String(app_name.to_string()));
397        }
398    }
399}
400
401fn build(
402    item: &SettingsItem,
403    types: &crate::field::FieldRegistry,
404    overrides: &dyn crate::field::OverrideResolver,
405    error: Option<Text>,
406    stored: &Value,
407    shell: &crate::Shell,
408) -> SettingsFormTemplate {
409    let fields = item
410        .fields
411        .iter()
412        .map(|f| {
413            let label = shell.tt(&f.label);
414            let control = match types.get(&f.field_type) {
415                Some(ft) => {
416                    let opts = ft
417                        .resolve_options(&f.options, types)
418                        .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
419                    let value = match stored.get(&f.name) {
420                        Some(Value::Array(rows)) => {
421                            crate::field::FieldValue::Json(Value::Array(rows.clone()))
422                        }
423                        Some(Value::String(text)) => {
424                            crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
425                        }
426                        Some(Value::Null) | None => {
427                            crate::field::FieldValue::Text(ft.to_control(None, &opts))
428                        }
429                        Some(other) => crate::field::FieldValue::Text(
430                            ft.to_control(Some(&other.to_string()), &opts),
431                        ),
432                    };
433                    let cx = crate::field::FieldCx {
434                        name: &f.name,
435                        id: &f.name,
436                        label: &label,
437                        value: &value,
438                        required: false,
439                        opts: &opts,
440                        base: &shell.base,
441                        i18n: shell.i18n(),
442                    };
443                    let scope = crate::field::OverrideScope {
444                        surface: crate::field::Surface::Field,
445                        view_key: &f.field_type,
446                        resource: Some(&item.code),
447                        field: Some(&f.name),
448                    };
449                    crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
450                }
451                None => String::new(),
452            };
453            FieldView {
454                label,
455                help: f.help.as_ref().map(|h| shell.tt(h)),
456                control,
457            }
458        })
459        .collect();
460    SettingsFormTemplate {
461        title: shell.tt(&item.label),
462        description: shell.tt(&item.description),
463        action: item.path(&shell.base),
464        error: error.map(|e| shell.tt(&e)),
465        fields,
466        shell: shell.clone(),
467    }
468}
469
470/// One category block in the context sidebar. Rendered by the shell.
471#[derive(Clone)]
472pub(crate) struct CategoryView {
473    pub(crate) name: String,
474    min_order: i32,
475    pub(crate) items: Vec<ItemView>,
476}
477
478/// One item in the context sidebar.
479#[derive(Clone)]
480pub(crate) struct ItemView {
481    pub(crate) label: String,
482    pub(crate) description: String,
483    pub(crate) path: String,
484    /// Inline SVG markup for the item's icon, rendered raw in the template.
485    pub(crate) icon: &'static str,
486    /// Whether this is the item currently open, so the sidebar highlights it.
487    pub(crate) active: bool,
488}
489
490#[derive(Template)]
491#[template(path = "settings_index.html")]
492struct SettingsIndexTemplate {
493    shell: crate::Shell,
494}
495
496struct FieldView {
497    label: String,
498    help: Option<String>,
499    /// The control, rendered by the field's own type.
500    control: String,
501}
502
503#[derive(Template)]
504#[template(path = "settings_form.html")]
505struct SettingsFormTemplate {
506    shell: crate::Shell,
507    title: String,
508    description: String,
509    action: String,
510    error: Option<String>,
511    fields: Vec<FieldView>,
512}
513
514#[cfg(test)]
515mod field_system_tests {
516    use super::*;
517
518    fn item(fields: Vec<FormField>) -> SettingsItem {
519        SettingsItem {
520            code: "acme.test".to_string(),
521            label: Text::new(""),
522            description: Text::new(""),
523            category: Text::new(""),
524            order: 0,
525            icon: None,
526            permission: None,
527            link: None,
528            fields,
529            route: None,
530        }
531    }
532
533    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
534        pairs
535            .iter()
536            .map(|(k, v)| (k.to_string(), v.to_string()))
537            .collect()
538    }
539
540    /// Settings and forms now share one field system, so a settings screen gets
541    /// every registered type and stores what that type says it stores.
542    #[test]
543    fn settings_store_what_the_field_type_says() {
544        let cfg = item(vec![
545            FormField::text("name", "Name"),
546            FormField::switch("enabled", "Enabled"),
547            FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
548            FormField::date("expires", "Expires"),
549        ]);
550        let stored = collect(
551            &cfg,
552            &crate::field::builtin_registry(),
553            &submitted(&[
554                ("name", "Acme"),
555                ("enabled", "on"),
556                ("freq", "daily"),
557                ("expires", "2026-12-31"),
558            ]),
559        )
560        .unwrap();
561
562        assert_eq!(stored["name"], "Acme");
563        // A bool, not the string "on": the switch field type decided that.
564        assert_eq!(stored["enabled"], serde_json::json!(true));
565        assert_eq!(stored["freq"], "daily");
566        assert_eq!(stored["expires"], "2026-12-31");
567    }
568
569    /// An unticked switch is absent from the submission and must store false,
570    /// or the box could never be cleared.
571    #[test]
572    fn an_unticked_switch_clears() {
573        let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
574        let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
575        assert_eq!(stored["enabled"], serde_json::json!(false));
576    }
577
578    /// The repeater works in settings because it is a field type, not a
579    /// settings-only widget: this is the whole point of the unification.
580    #[test]
581    fn a_repeater_works_in_settings() {
582        let cfg = item(vec![FormField::repeater(
583            "rules",
584            "Rules",
585            vec![
586                FormField::text("path", "Path"),
587                FormField::switch("allow", "Allow"),
588            ],
589        )]);
590        let stored = collect(
591            &cfg,
592            &crate::field::builtin_registry(),
593            &submitted(&[
594                ("rules[0][path]", "/private"),
595                ("rules[1][path]", "/public"),
596                ("rules[1][allow]", "on"),
597            ]),
598        )
599        .unwrap();
600
601        let rows = stored["rules"].as_array().unwrap();
602        assert_eq!(rows.len(), 2);
603        assert_eq!(rows[0]["path"], "/private");
604        assert_eq!(rows[0]["allow"], serde_json::json!(false));
605        assert_eq!(rows[1]["allow"], serde_json::json!(true));
606    }
607
608    #[test]
609    fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
610        let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
611        assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
612    }
613
614    #[test]
615    fn the_form_renders_each_control_through_its_type() {
616        let cfg = item(vec![
617            FormField::switch("enabled", "Enabled"),
618            FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
619        ]);
620        let html = build(
621            &cfg,
622            &crate::field::builtin_registry(),
623            &crate::field::NoOverrides,
624            None,
625            &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
626            &crate::Shell::test(),
627        )
628        .render()
629        .unwrap();
630
631        assert!(html.contains(r#"type="checkbox""#));
632        assert!(html.contains(r#"name="rules[0][path]""#));
633        assert!(html.contains(r#"value="/a""#));
634        assert!(html.contains("lat-repeater__blank"));
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use laterite_core::Db;
642
643    #[test]
644    fn brand_form_prefills_app_name_from_config_when_unset() {
645        let brand = brand::settings_item();
646        // Unset: the field prefills from the configured application name.
647        let mut unset = Value::Object(Map::new());
648        prefill_from_config(&brand, &mut unset, "Configured Name");
649        assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
650        // Already set: the stored value is left untouched.
651        let mut set = serde_json::json!({ "app_name": "Acme" });
652        prefill_from_config(&brand, &mut set, "Configured Name");
653        assert_eq!(set["app_name"], serde_json::json!("Acme"));
654        // A non-brand item is not prefilled.
655        let mut other = Value::Object(Map::new());
656        prefill_from_config(&item(), &mut other, "Configured Name");
657        assert!(other.get("app_name").is_none());
658    }
659
660    fn item() -> SettingsItem {
661        SettingsItem {
662            code: "test.log".to_string(),
663            label: "Log Settings".into(),
664            description: "What the log records.".into(),
665            category: "Logs".into(),
666            order: 10,
667            icon: None,
668            permission: None,
669            link: None,
670            fields: vec![
671                FormField::switch("log_events", "Log events"),
672                FormField::switch("log_requests", "Log requests"),
673                FormField::text("retention_days", "Retention (days)"),
674            ],
675            route: None,
676        }
677    }
678
679    fn state(db: Db) -> AdminState {
680        AdminState::new(
681            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
682            db,
683        )
684    }
685
686    /// A fresh test database with the settings table migrated in, on whichever
687    /// backend the run targets. Hold the returned guard for the test's lifetime.
688    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
689        laterite_core::testing::connect_test(&[migrations()]).await
690    }
691
692    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
693        pairs
694            .iter()
695            .map(|(k, v)| (k.to_string(), v.to_string()))
696            .collect()
697    }
698
699    #[test]
700    fn group_orders_categories_then_items() {
701        let items = vec![
702            SettingsItem {
703                code: "b".into(),
704                label: "Beta".into(),
705                description: String::new().into(),
706                category: "System".into(),
707                order: 20,
708                icon: None,
709                permission: None,
710                link: None,
711                fields: vec![],
712                route: None,
713            },
714            SettingsItem {
715                code: "a".into(),
716                label: "Alpha".into(),
717                description: String::new().into(),
718                category: "System".into(),
719                order: 10,
720                icon: None,
721                permission: None,
722                link: None,
723                fields: vec![],
724                route: None,
725            },
726            SettingsItem {
727                code: "l".into(),
728                label: "Logs".into(),
729                description: String::new().into(),
730                category: "Logs".into(),
731                order: 5,
732                icon: None,
733                permission: None,
734                link: None,
735                fields: vec![],
736                route: None,
737            },
738        ];
739        let groups = sidebar_groups(&items, "/admin", None, &Translator::new("en"));
740        // "Logs" (min order 5) comes before "System" (min order 10).
741        assert_eq!(groups[0].name, "Logs");
742        assert_eq!(groups[1].name, "System");
743        // Within "System", Alpha (10) before Beta (20).
744        assert_eq!(groups[1].items[0].label, "Alpha");
745        assert_eq!(groups[1].items[1].label, "Beta");
746        // Nothing is active when no code is given.
747        assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
748    }
749
750    #[test]
751    fn group_marks_only_the_active_item() {
752        let items = vec![item()];
753        let groups = sidebar_groups(&items, "/admin", Some("test.log"), &Translator::new("en"));
754        let active: Vec<&str> = groups
755            .iter()
756            .flat_map(|g| &g.items)
757            .filter(|i| i.active)
758            .map(|i| i.label.as_str())
759            .collect();
760        assert_eq!(active, ["Log Settings"]);
761    }
762
763    #[test]
764    fn settings_model_item_path_is_its_form() {
765        // Unresolved (no module namespace yet), the fallback still builds a
766        // path from the code's segments rather than using the dotted key.
767        assert_eq!(item().path("/admin"), "/admin/settings/test/log");
768        // The mount is honoured, so a relocated panel keeps consistent links.
769        assert_eq!(item().path("/manage"), "/manage/settings/test/log");
770
771        // Once boot resolves it into the module's namespace, that wins.
772        let mut resolved = item();
773        resolved.mount_at("/acme/logging/log");
774        assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
775    }
776
777    #[test]
778    fn link_item_path_follows_the_link() {
779        let admins = crate::builtin_settings()
780            .into_iter()
781            .find(|i| i.code == "backend.administrators")
782            .unwrap();
783        assert_eq!(admins.category.source(), "Users");
784        assert!(admins.link.is_some());
785        assert!(admins.fields.is_empty());
786        // links to the resource list, not a settings form
787        assert_eq!(admins.path("/admin"), "/admin/users");
788        assert!(crate::builtin_settings()
789            .iter()
790            .any(|i| i.code == "backend.roles"));
791    }
792
793    #[tokio::test]
794    async fn update_persists_typed_values() {
795        let (db, _guard) = test_db().await;
796        let st = state(db.clone());
797
798        let it = item();
799        let resp = update(
800            &st,
801            &it,
802            // log_requests is absent, as an unchecked checkbox would be.
803            data(&[("log_events", "on"), ("retention_days", "30")]),
804            crate::Shell::test(),
805            &crate::session::SessionHandle::from_blob(None),
806            &crate::audit::test_actor(),
807        )
808        .await;
809        assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
810
811        let stored = store::get(&db, "test.log").await.unwrap().unwrap();
812        assert_eq!(stored["log_events"], serde_json::json!(true));
813        assert_eq!(stored["log_requests"], serde_json::json!(false));
814        assert_eq!(stored["retention_days"], serde_json::json!("30"));
815    }
816
817    #[test]
818    fn index_renders() {
819        let resp = index(crate::Shell::test());
820        assert_eq!(resp.status(), axum::http::StatusCode::OK);
821    }
822
823    #[tokio::test]
824    async fn edit_form_renders_for_unset_item() {
825        let (db, _guard) = test_db().await;
826        let st = state(db);
827        // No stored value yet: the form still renders (fields fall back to defaults).
828        let it = item();
829        let resp = edit_form(&st, &it, crate::Shell::test()).await;
830        assert_eq!(resp.status(), axum::http::StatusCode::OK);
831    }
832}