1pub mod brand;
16pub mod mail;
17pub mod mail_brand;
18pub mod migrations;
19pub mod store;
20
21pub use brand::BrandSetting;
22pub use migrations::{migrations, MODULE_ID};
23pub use store::{
24 get, get_for, load, load_for, save, save_for, set, set_for, PreferencesError, PreferencesModel,
25 SettingsError, SettingsModel,
26};
27
28pub struct SettingsModule;
30
31impl laterite_core::Module for SettingsModule {
32 fn id(&self) -> laterite_core::ModuleId {
33 laterite_core::ModuleId::new(MODULE_ID)
34 }
35 fn migrations(&self) -> laterite_core::MigrationSet {
36 migrations()
37 }
38 fn register(&self, registry: &mut laterite_core::Registry) {
39 registry.add(laterite_core::cache::CacheNamespace::new("values"));
40 }
41}
42
43pub const NAMESPACE: &str = "laterite.settings::values";
46
47#[derive(Clone)]
52pub struct Settings {
53 db: laterite_core::Db,
54 cache: laterite_core::Cache,
55}
56
57impl Settings {
58 pub fn new(db: laterite_core::Db, cache: laterite_core::Cache) -> Self {
59 cache.allow(NAMESPACE);
60 Self { db, cache }
61 }
62
63 pub async fn save<T: SettingsModel>(&self, model: &T) -> Result<(), SettingsError> {
65 store::save(&self.db, model).await?;
66 let _ = self.cache.bump(NAMESPACE).await;
67 Ok(())
68 }
69
70 pub async fn set(&self, code: &str, value: &serde_json::Value) -> Result<(), SettingsError> {
72 store::set(&self.db, code, value).await?;
73 let _ = self.cache.bump(NAMESPACE).await;
74 Ok(())
75 }
76}
77
78#[cfg(test)]
79mod namespace_tests {
80 use super::*;
81 use laterite_core::{Module, ModuleId};
82
83 #[test]
84 fn the_registered_namespace_is_the_constant() {
85 let owner = ModuleId::new(MODULE_ID);
86 assert_eq!(SettingsModule.id(), owner);
87 assert_eq!(
88 laterite_core::cache::CacheNamespace::new("values").qualified(owner),
89 NAMESPACE
90 );
91 }
92}
93
94use crate::form::ValueFieldView;
95use std::collections::HashMap;
96
97use askama::Template;
98use axum::response::{IntoResponse, Redirect, Response};
99use laterite_core::{t, Text, Translator};
100use serde::Serialize;
101use serde_json::{Map, Value};
102
103use crate::{render, render_error, AdminState};
104
105pub use crate::form::FormField;
112
113#[derive(Debug, Clone, Serialize)]
116#[non_exhaustive]
117pub struct SettingsItem {
118 pub code: String,
126 pub label: Text,
128 pub description: Text,
129 pub category: Text,
131 pub hint: Option<Text>,
135 pub confirm_leave: bool,
137 pub slot: i32,
140 pub order: i32,
142 pub icon: Option<String>,
145 pub permission: Option<String>,
148 pub link: Option<String>,
152 pub fields: Vec<FormField>,
153 pub(crate) route: Option<String>,
156 pub per_operator: bool,
159 pub actions: Vec<SettingsAction>,
162}
163
164#[derive(Debug, Clone, Serialize)]
166pub struct SettingsAction {
167 pub label: Text,
168 pub path: String,
170}
171
172pub(crate) const MAX_DESCRIPTION: usize = 72;
177
178impl SettingsItem {
179 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
185 Self {
186 code: code.into(),
187 label: label.into(),
188 description: Text::from(""),
189 category: Text::from(""),
190 hint: None,
191 confirm_leave: true,
192 slot: laterite_core::arrange::slot::APPLICATION,
193 order: 0,
194 icon: None,
195 permission: None,
196 link: None,
197 fields,
198 route: None,
199 per_operator: false,
200 actions: Vec::new(),
201 }
202 }
203
204 pub fn action(mut self, label: impl Into<Text>, path: impl Into<String>) -> Self {
206 self.actions.push(SettingsAction {
207 label: label.into(),
208 path: path.into(),
209 });
210 self
211 }
212
213 pub fn per_operator(mut self) -> Self {
216 self.per_operator = true;
217 self
218 }
219
220 pub fn description(mut self, text: impl Into<Text>) -> Self {
227 let text = text.into();
228 debug_assert!(
229 text.source().chars().count() <= MAX_DESCRIPTION,
230 "settings description is {} characters, over the {MAX_DESCRIPTION} that \
231 fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
232 text.source().chars().count(),
233 text.source(),
234 );
235 self.description = text;
236 self
237 }
238
239 pub fn hint(mut self, text: impl Into<Text>) -> Self {
242 self.hint = Some(text.into());
243 self
244 }
245
246 pub fn confirm_leave(mut self, ask: bool) -> Self {
249 self.confirm_leave = ask;
250 self
251 }
252
253 pub fn category(mut self, text: impl Into<Text>) -> Self {
255 self.category = text.into();
256 self
257 }
258
259 pub fn order(mut self, order: i32) -> Self {
261 self.order = order;
262 self
263 }
264
265 pub fn slot(mut self, slot: i32) -> Self {
267 self.slot = slot;
268 self
269 }
270
271 pub fn icon(mut self, icon: impl Into<String>) -> Self {
273 self.icon = Some(icon.into());
274 self
275 }
276
277 pub fn permission(mut self, permission: impl Into<String>) -> Self {
279 self.permission = Some(permission.into());
280 self
281 }
282
283 pub fn link(mut self, link: impl Into<String>) -> Self {
286 self.link = Some(link.into());
287 self
288 }
289
290 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
293 self.route = Some(route.into());
294 }
295
296 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
303 self.route = Some(route.into());
304 self
305 }
306
307 pub(crate) fn route(&self) -> String {
314 match &self.route {
315 Some(route) => route.clone(),
316 None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
317 None => format!("/settings/{}", self.code.replace('.', "/")),
318 }
319 }
320
321 pub fn path(&self, admin_path: &str) -> String {
325 match &self.link {
326 Some(link) => format!("{admin_path}{link}"),
327 None => format!("{admin_path}{}", self.route()),
328 }
329 }
330}
331
332pub(crate) fn index(shell: crate::Shell) -> Response {
335 render(SettingsIndexTemplate { shell })
336}
337
338pub(crate) async fn edit_form(
341 state: &AdminState,
342 item: &SettingsItem,
343 shell: crate::Shell,
344 user: &laterite_auth::AuthenticatedUser,
345) -> Response {
346 let mut stored = match stored_value(state, item, user).await {
347 Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
348 None => return render_error(),
349 };
350 prefill_from_config(item, &mut stored, &state.app_name);
353 render(build(
354 item,
355 &state.field_types,
356 state.overrides.as_ref(),
357 None,
358 &stored,
359 &shell,
360 ))
361}
362
363pub(crate) async fn update(
365 state: &AdminState,
366 item: &SettingsItem,
367 data: HashMap<String, String>,
368 shell: crate::Shell,
369 session: &crate::session::SessionHandle,
370 user: &laterite_auth::AuthenticatedUser,
371) -> Response {
372 let value = match collect(item, &state.field_types, &data) {
375 Ok(mut value) => {
378 if let Some(Some(Value::Object(previous))) = stored_value(state, item, user).await {
379 if let Value::Object(map) = &mut value {
380 for (key, old) in previous {
381 map.entry(key).or_insert(old);
382 }
383 }
384 }
385 value
386 }
387 Err(message) => {
388 let stored = stored_value(state, item, user)
389 .await
390 .flatten()
391 .unwrap_or(Value::Null);
392 return render(build(
393 item,
394 &state.field_types,
395 state.overrides.as_ref(),
396 Some(message),
397 &stored,
398 &shell,
399 ));
400 }
401 };
402 let written = if item.per_operator {
403 store::set_for(&state.db, user.user.id, &item.code, &value)
404 .await
405 .is_ok()
406 } else {
407 state.settings_store().set(&item.code, &value).await.is_ok()
408 };
409 match written {
410 true => {
411 if item.code == mail_brand::MailBrandSetting::CODE {
413 let brand: mail_brand::MailBrandSetting =
414 serde_json::from_value(value.clone()).unwrap_or_default();
415 state.mail.templates().set_brand_css(&brand.css()).await;
416 }
417 if item.code == mail::MailSetting::CODE {
419 let setting: mail::MailSetting =
420 serde_json::from_value(value.clone()).unwrap_or_default();
421 let config = mail::effective(&state.mail_config, &setting, &state.crypt);
422 if let Err(e) = state.mail.reconfigure(&config) {
423 session.push_flash(
424 crate::session::FlashLevel::Error,
425 t!(
426 "Saved, but the transport could not be set up: {error}",
427 error = e.to_string()
428 ),
429 );
430 }
431 }
432 if !item.per_operator {
436 crate::audit::record(
437 state,
438 user,
439 "backend.settings.update",
440 Some("settings"),
441 Some(item.code.as_str()),
442 None,
443 None,
444 )
445 .await;
446 }
447 session.push_flash(
448 crate::session::FlashLevel::Success,
449 if item.per_operator {
450 t!("Preferences saved.")
451 } else {
452 t!("Settings saved.")
453 },
454 );
455 Redirect::to(&item.path(&state.admin_path)).into_response()
459 }
460 false => render(build(
461 item,
462 &state.field_types,
463 state.overrides.as_ref(),
464 Some(t!("Could not save. Please try again.")),
465 &value,
466 &shell,
467 )),
468 }
469}
470
471async fn stored_value(
474 state: &AdminState,
475 item: &SettingsItem,
476 user: &laterite_auth::AuthenticatedUser,
477) -> Option<Option<Value>> {
478 if item.per_operator {
479 store::get_for(&state.db, user.user.id, &item.code)
480 .await
481 .ok()
482 } else {
483 store::get(&state.db, &item.code).await.ok()
484 }
485}
486
487fn collect(
495 item: &SettingsItem,
496 types: &crate::field::FieldRegistry,
497 data: &HashMap<String, String>,
498) -> Result<Value, Text> {
499 crate::form::value_collect(&item.fields, types, data)
500}
501
502pub const HIERARCHY: &str = "backend.settings";
505
506pub(crate) fn hierarchy() -> laterite_core::arrange::Hierarchy {
507 laterite_core::arrange::Hierarchy::new(HIERARCHY).operator_adjustable()
508}
509
510struct Placed<'a> {
511 item: &'a SettingsItem,
512 position: laterite_core::arrange::Position,
513}
514
515impl laterite_core::arrange::Arranged for Placed<'_> {
516 fn key(&self) -> &str {
517 &self.item.code
518 }
519 fn position(&self) -> &laterite_core::arrange::Position {
520 &self.position
521 }
522 fn label(&self) -> &str {
523 self.item.label.source()
524 }
525}
526
527pub(crate) fn log_arrangement(items: &[SettingsItem], order: Option<&[String]>) {
530 let placed: Vec<Placed<'_>> = items
531 .iter()
532 .map(|item| Placed {
533 item,
534 position: laterite_core::arrange::Position::new()
535 .slot(item.slot)
536 .order(item.order),
537 })
538 .collect();
539 let arranged = laterite_core::arrange::arrange(
540 &hierarchy(),
541 placed,
542 laterite_core::arrange::Overrides {
543 deployment: order,
544 operator: None,
545 },
546 )
547 .unwrap_or_default();
548 let keys: Vec<String> = arranged
549 .iter()
550 .map(|p| {
551 format!(
552 "{} ({})",
553 p.item.code,
554 laterite_core::arrange::slot::name(p.item.slot)
555 )
556 })
557 .collect();
558 tracing::info!(hierarchy = HIERARCHY, arrangement = %keys.join(" > "));
559}
560
561pub(crate) fn sidebar_groups(
566 items: &[SettingsItem],
567 admin_path: &str,
568 active_code: Option<&str>,
569 tr: &Translator,
570 icons: crate::icons::Icons<'_>,
571 order: Option<&[String]>,
572 own_order: Option<&[String]>,
573) -> Vec<CategoryView> {
574 let placed: Vec<Placed<'_>> = items
575 .iter()
576 .map(|item| Placed {
577 item,
578 position: laterite_core::arrange::Position::new()
579 .slot(item.slot)
580 .order(item.order),
581 })
582 .collect();
583 let arranged = laterite_core::arrange::arrange(
584 &hierarchy(),
585 placed,
586 laterite_core::arrange::Overrides {
587 deployment: order,
588 operator: own_order,
589 },
590 )
591 .unwrap_or_default();
592 let mut groups: Vec<CategoryView> = Vec::new();
593 for placed in arranged {
594 let i = placed.item;
595 let category = tr.t(&i.category);
596 let view = ItemView {
597 key: i.code.clone(),
598 label: tr.t(&i.label),
599 description: tr.t(&i.description),
600 path: i.path(admin_path),
601 icon: icons.render(i.icon.as_deref()),
602 active: active_code == Some(i.code.as_str()),
603 };
604 match groups.iter_mut().find(|g| g.name == category) {
605 Some(group) => group.items.push(view),
606 None => groups.push(CategoryView {
607 name: category,
608 items: vec![view],
609 }),
610 }
611 }
612 groups
613}
614
615fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
622 if item.code != brand::BrandSetting::CODE {
623 return;
624 }
625 if let Value::Object(map) = stored {
626 let blank = map
627 .get("app_name")
628 .and_then(Value::as_str)
629 .unwrap_or("")
630 .trim()
631 .is_empty();
632 if blank {
633 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
634 }
635 }
636}
637
638fn build(
639 item: &SettingsItem,
640 types: &crate::field::FieldRegistry,
641 overrides: &dyn crate::field::OverrideResolver,
642 error: Option<Text>,
643 stored: &Value,
644 shell: &crate::Shell,
645) -> SettingsFormTemplate {
646 let fields =
647 crate::form::value_views(&item.fields, types, overrides, stored, shell, &item.code);
648 SettingsFormTemplate {
649 title: shell.tt(&item.label),
650 description: shell.tt(&item.description),
651 hint: item.hint.as_ref().map(|h| shell.tt(h)),
652 action: item.path(&shell.base),
653 error: error.map(|e| shell.tt(&e)),
654 fields,
655 leave_off: !item.confirm_leave,
656 actions: item
657 .actions
658 .iter()
659 .map(|a| ActionView {
660 label: shell.tt(&a.label),
661 path: format!("{}{}", shell.base, a.path),
662 })
663 .collect(),
664 shell: shell.clone(),
665 }
666}
667
668#[derive(Clone)]
670pub(crate) struct CategoryView {
671 pub(crate) name: String,
672 pub(crate) items: Vec<ItemView>,
673}
674
675impl CategoryView {
676 pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
678 Self { name, items }
679 }
680}
681
682#[derive(Clone)]
684pub(crate) struct ItemView {
685 pub(crate) key: String,
687 pub(crate) label: String,
688 pub(crate) description: String,
689 pub(crate) path: String,
690 pub(crate) icon: String,
692 pub(crate) active: bool,
694}
695
696#[derive(Template)]
697#[template(path = "settings_index.html")]
698struct SettingsIndexTemplate {
699 shell: crate::Shell,
700}
701
702#[derive(Template)]
703#[template(path = "settings_form.html")]
704struct SettingsFormTemplate {
705 shell: crate::Shell,
706 title: String,
707 description: String,
708 hint: Option<String>,
711 action: String,
712 error: Option<String>,
713 fields: Vec<ValueFieldView>,
714 leave_off: bool,
716 actions: Vec<ActionView>,
717}
718
719struct ActionView {
720 label: String,
721 path: String,
722}
723
724#[cfg(test)]
725mod field_system_tests {
726 use super::*;
727
728 #[test]
729 #[should_panic(expected = "over the 72")]
730 fn a_lecture_of_a_description_is_refused() {
731 SettingsItem::new("acme.test", "Test", Vec::new()).description(
734 "What search engines and other crawlers may visit. The admin panel \
735 is always excluded, whatever you put here.",
736 );
737 }
738
739 #[test]
740 fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
741 let item = SettingsItem::new("acme.test", "Test", Vec::new())
742 .description("Which crawlers may visit, and what they may not.")
743 .hint(
744 "The admin panel is always excluded, whatever you put here, and a \
745 crawler obeys only the block that names it.",
746 );
747 assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
748 assert!(item.hint.is_some());
750 }
751
752 fn item(fields: Vec<FormField>) -> SettingsItem {
753 SettingsItem {
754 code: "acme.test".to_string(),
755 label: Text::new(""),
756 description: Text::new(""),
757 category: Text::new(""),
758 hint: None,
759 confirm_leave: true,
760 slot: laterite_core::arrange::slot::APPLICATION,
761 order: 0,
762 icon: None,
763 permission: None,
764 link: None,
765 fields,
766 route: None,
767 per_operator: false,
768 actions: Vec::new(),
769 }
770 }
771
772 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
773 pairs
774 .iter()
775 .map(|(k, v)| (k.to_string(), v.to_string()))
776 .collect()
777 }
778
779 #[test]
782 fn settings_store_what_the_field_type_says() {
783 let cfg = item(vec![
784 FormField::text("name", "Name"),
785 FormField::switch("enabled", "Enabled"),
786 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
787 FormField::date("expires", "Expires"),
788 ]);
789 let stored = collect(
790 &cfg,
791 &crate::field::builtin_registry(),
792 &submitted(&[
793 ("name", "Acme"),
794 ("enabled", "on"),
795 ("freq", "daily"),
796 ("expires", "2026-12-31"),
797 ]),
798 )
799 .unwrap();
800
801 assert_eq!(stored["name"], "Acme");
802 assert_eq!(stored["enabled"], serde_json::json!(true));
804 assert_eq!(stored["freq"], "daily");
805 assert_eq!(stored["expires"], "2026-12-31");
806 }
807
808 #[test]
811 fn an_unticked_switch_clears() {
812 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
813 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
814 assert_eq!(stored["enabled"], serde_json::json!(false));
815 }
816
817 #[test]
820 fn a_repeater_works_in_settings() {
821 let cfg = item(vec![FormField::repeater(
822 "rules",
823 "Rules",
824 vec![
825 FormField::text("path", "Path"),
826 FormField::switch("allow", "Allow"),
827 ],
828 )]);
829 let stored = collect(
830 &cfg,
831 &crate::field::builtin_registry(),
832 &submitted(&[
833 ("rules[0][path]", "/private"),
834 ("rules[1][path]", "/public"),
835 ("rules[1][allow]", "on"),
836 ]),
837 )
838 .unwrap();
839
840 let rows = stored["rules"].as_array().unwrap();
841 assert_eq!(rows.len(), 2);
842 assert_eq!(rows[0]["path"], "/private");
843 assert_eq!(rows[0]["allow"], serde_json::json!(false));
844 assert_eq!(rows[1]["allow"], serde_json::json!(true));
845 }
846
847 #[test]
848 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
849 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
850 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
851 }
852
853 #[test]
854 fn the_form_renders_each_control_through_its_type() {
855 let cfg = item(vec![
856 FormField::switch("enabled", "Enabled"),
857 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
858 ]);
859 let html = build(
860 &cfg,
861 &crate::field::builtin_registry(),
862 &crate::field::NoOverrides,
863 None,
864 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
865 &crate::Shell::test(),
866 )
867 .render()
868 .unwrap();
869
870 assert!(html.contains(r#"type="checkbox""#));
871 assert!(html.contains(r#"name="rules[0][path]""#));
872 assert!(html.contains(r#"value="/a""#));
873 assert!(html.contains("lat-repeater__blank"));
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use laterite_core::Db;
881
882 #[test]
883 fn brand_form_prefills_app_name_from_config_when_unset() {
884 let brand = brand::settings_item();
885 let mut unset = Value::Object(Map::new());
887 prefill_from_config(&brand, &mut unset, "Configured Name");
888 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
889 let mut set = serde_json::json!({ "app_name": "Acme" });
891 prefill_from_config(&brand, &mut set, "Configured Name");
892 assert_eq!(set["app_name"], serde_json::json!("Acme"));
893 let mut other = Value::Object(Map::new());
895 prefill_from_config(&item(), &mut other, "Configured Name");
896 assert!(other.get("app_name").is_none());
897 }
898
899 fn item() -> SettingsItem {
900 SettingsItem {
901 code: "test.log".to_string(),
902 label: "Log Settings".into(),
903 description: "What the log records.".into(),
904 category: "Logs".into(),
905 hint: None,
906 confirm_leave: true,
907 slot: laterite_core::arrange::slot::APPLICATION,
908 order: 10,
909 icon: None,
910 permission: None,
911 link: None,
912 fields: vec![
913 FormField::switch("log_events", "Log events"),
914 FormField::switch("log_requests", "Log requests"),
915 FormField::text("retention_days", "Retention (days)"),
916 ],
917 route: None,
918 per_operator: false,
919 actions: Vec::new(),
920 }
921 }
922
923 fn state(db: Db) -> AdminState {
924 AdminState::new(
925 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
926 db,
927 )
928 }
929
930 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
933 laterite_core::testing::connect_test(&[migrations()]).await
934 }
935
936 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
937 pairs
938 .iter()
939 .map(|(k, v)| (k.to_string(), v.to_string()))
940 .collect()
941 }
942
943 #[test]
944 fn group_orders_categories_then_items() {
945 let items = vec![
946 SettingsItem {
947 code: "b".into(),
948 label: "Beta".into(),
949 description: String::new().into(),
950 category: "System".into(),
951 hint: None,
952 confirm_leave: true,
953 slot: laterite_core::arrange::slot::APPLICATION,
954 order: 20,
955 icon: None,
956 permission: None,
957 link: None,
958 fields: vec![],
959 route: None,
960 per_operator: false,
961 actions: Vec::new(),
962 },
963 SettingsItem {
964 code: "a".into(),
965 label: "Alpha".into(),
966 description: String::new().into(),
967 category: "System".into(),
968 hint: None,
969 confirm_leave: true,
970 slot: laterite_core::arrange::slot::APPLICATION,
971 order: 10,
972 icon: None,
973 permission: None,
974 link: None,
975 fields: vec![],
976 route: None,
977 per_operator: false,
978 actions: Vec::new(),
979 },
980 SettingsItem {
981 code: "l".into(),
982 label: "Logs".into(),
983 description: String::new().into(),
984 category: "Logs".into(),
985 hint: None,
986 confirm_leave: true,
987 slot: laterite_core::arrange::slot::APPLICATION,
988 order: 5,
989 icon: None,
990 permission: None,
991 link: None,
992 fields: vec![],
993 route: None,
994 per_operator: false,
995 actions: Vec::new(),
996 },
997 ];
998 let groups = sidebar_groups(
999 &items,
1000 "/admin",
1001 None,
1002 &Translator::new("en"),
1003 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
1004 None,
1005 None,
1006 );
1007 assert_eq!(groups[0].name, "Logs");
1009 assert_eq!(groups[1].name, "System");
1010 assert_eq!(groups[1].items[0].label, "Alpha");
1012 assert_eq!(groups[1].items[1].label, "Beta");
1013 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
1015 }
1016
1017 #[test]
1018 fn group_marks_only_the_active_item() {
1019 let items = vec![item()];
1020 let groups = sidebar_groups(
1021 &items,
1022 "/admin",
1023 Some("test.log"),
1024 &Translator::new("en"),
1025 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
1026 None,
1027 None,
1028 );
1029 let active: Vec<&str> = groups
1030 .iter()
1031 .flat_map(|g| &g.items)
1032 .filter(|i| i.active)
1033 .map(|i| i.label.as_str())
1034 .collect();
1035 assert_eq!(active, ["Log Settings"]);
1036 }
1037
1038 #[test]
1039 fn settings_model_item_path_is_its_form() {
1040 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
1043 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
1045
1046 let mut resolved = item();
1048 resolved.mount_at("/acme/logging/log");
1049 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
1050 }
1051
1052 #[test]
1053 fn link_item_path_follows_the_link() {
1054 let admins = crate::builtin_settings()
1055 .into_iter()
1056 .find(|i| i.code == "backend.administrators")
1057 .unwrap();
1058 assert_eq!(admins.category.source(), "Users");
1059 assert!(admins.link.is_some());
1060 assert!(admins.fields.is_empty());
1061 assert_eq!(admins.path("/admin"), "/admin/users");
1063 assert!(crate::builtin_settings()
1064 .iter()
1065 .any(|i| i.code == "backend.roles"));
1066 }
1067
1068 #[tokio::test]
1069 async fn update_persists_typed_values() {
1070 let (db, _guard) = test_db().await;
1071 let st = state(db.clone());
1072
1073 let it = item();
1074 let resp = update(
1075 &st,
1076 &it,
1077 data(&[("log_events", "on"), ("retention_days", "30")]),
1079 crate::Shell::test(),
1080 &crate::session::SessionHandle::from_blob(None),
1081 &crate::audit::test_actor(),
1082 )
1083 .await;
1084 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
1085
1086 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
1087 assert_eq!(stored["log_events"], serde_json::json!(true));
1088 assert_eq!(stored["log_requests"], serde_json::json!(false));
1089 assert_eq!(stored["retention_days"], serde_json::json!("30"));
1090 }
1091
1092 #[test]
1093 fn index_renders() {
1094 let resp = index(crate::Shell::test());
1095 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1096 }
1097
1098 #[tokio::test]
1099 async fn edit_form_renders_for_unset_item() {
1100 let (db, _guard) = test_db().await;
1101 let st = state(db);
1102 let it = item();
1104 let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
1105 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1106 }
1107}