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