1pub mod brand;
16pub mod migrations;
17pub mod store;
18
19pub use brand::BrandSetting;
20pub use migrations::{migrations, MODULE_ID};
21pub use store::{
22 get, get_for, load, load_for, save, save_for, set, set_for, PreferencesError, PreferencesModel,
23 SettingsError, SettingsModel,
24};
25
26pub struct SettingsModule;
28
29impl laterite_core::Module for SettingsModule {
30 fn id(&self) -> laterite_core::ModuleId {
31 laterite_core::ModuleId::new(MODULE_ID)
32 }
33 fn migrations(&self) -> laterite_core::MigrationSet {
34 migrations()
35 }
36 fn register(&self, registry: &mut laterite_core::Registry) {
37 registry.add(laterite_core::cache::CacheNamespace::new("values"));
38 }
39}
40
41pub const NAMESPACE: &str = "laterite.settings::values";
44
45#[derive(Clone)]
50pub struct Settings {
51 db: laterite_core::Db,
52 cache: laterite_core::Cache,
53}
54
55impl Settings {
56 pub fn new(db: laterite_core::Db, cache: laterite_core::Cache) -> Self {
57 cache.allow(NAMESPACE);
58 Self { db, cache }
59 }
60
61 pub async fn save<T: SettingsModel>(&self, model: &T) -> Result<(), SettingsError> {
63 store::save(&self.db, model).await?;
64 let _ = self.cache.bump(NAMESPACE).await;
65 Ok(())
66 }
67
68 pub async fn set(&self, code: &str, value: &serde_json::Value) -> Result<(), SettingsError> {
70 store::set(&self.db, code, value).await?;
71 let _ = self.cache.bump(NAMESPACE).await;
72 Ok(())
73 }
74}
75
76#[cfg(test)]
77mod namespace_tests {
78 use super::*;
79 use laterite_core::{Module, ModuleId};
80
81 #[test]
82 fn the_registered_namespace_is_the_constant() {
83 let owner = ModuleId::new(MODULE_ID);
84 assert_eq!(SettingsModule.id(), owner);
85 assert_eq!(
86 laterite_core::cache::CacheNamespace::new("values").qualified(owner),
87 NAMESPACE
88 );
89 }
90}
91
92use crate::form::ValueFieldView;
93use std::collections::HashMap;
94
95use askama::Template;
96use axum::response::{IntoResponse, Redirect, Response};
97use laterite_core::{t, Text, Translator};
98use serde::Serialize;
99use serde_json::{Map, Value};
100
101use crate::{render, render_error, AdminState};
102
103pub use crate::form::FormField;
110
111#[derive(Debug, Clone, Serialize)]
114#[non_exhaustive]
115pub struct SettingsItem {
116 pub code: String,
124 pub label: Text,
126 pub description: Text,
127 pub category: Text,
129 pub hint: Option<Text>,
133 pub confirm_leave: bool,
135 pub slot: i32,
138 pub order: i32,
140 pub icon: Option<String>,
143 pub permission: Option<String>,
146 pub link: Option<String>,
150 pub fields: Vec<FormField>,
151 pub(crate) route: Option<String>,
154 pub per_operator: bool,
157}
158
159pub(crate) const MAX_DESCRIPTION: usize = 72;
164
165impl SettingsItem {
166 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
172 Self {
173 code: code.into(),
174 label: label.into(),
175 description: Text::from(""),
176 category: Text::from(""),
177 hint: None,
178 confirm_leave: true,
179 slot: laterite_core::arrange::slot::APPLICATION,
180 order: 0,
181 icon: None,
182 permission: None,
183 link: None,
184 fields,
185 route: None,
186 per_operator: false,
187 }
188 }
189
190 pub fn per_operator(mut self) -> Self {
193 self.per_operator = true;
194 self
195 }
196
197 pub fn description(mut self, text: impl Into<Text>) -> Self {
204 let text = text.into();
205 debug_assert!(
206 text.source().chars().count() <= MAX_DESCRIPTION,
207 "settings description is {} characters, over the {MAX_DESCRIPTION} that \
208 fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
209 text.source().chars().count(),
210 text.source(),
211 );
212 self.description = text;
213 self
214 }
215
216 pub fn hint(mut self, text: impl Into<Text>) -> Self {
219 self.hint = Some(text.into());
220 self
221 }
222
223 pub fn confirm_leave(mut self, ask: bool) -> Self {
226 self.confirm_leave = ask;
227 self
228 }
229
230 pub fn category(mut self, text: impl Into<Text>) -> Self {
232 self.category = text.into();
233 self
234 }
235
236 pub fn order(mut self, order: i32) -> Self {
238 self.order = order;
239 self
240 }
241
242 pub fn slot(mut self, slot: i32) -> Self {
244 self.slot = slot;
245 self
246 }
247
248 pub fn icon(mut self, icon: impl Into<String>) -> Self {
250 self.icon = Some(icon.into());
251 self
252 }
253
254 pub fn permission(mut self, permission: impl Into<String>) -> Self {
256 self.permission = Some(permission.into());
257 self
258 }
259
260 pub fn link(mut self, link: impl Into<String>) -> Self {
263 self.link = Some(link.into());
264 self
265 }
266
267 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
270 self.route = Some(route.into());
271 }
272
273 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
280 self.route = Some(route.into());
281 self
282 }
283
284 pub(crate) fn route(&self) -> String {
291 match &self.route {
292 Some(route) => route.clone(),
293 None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
294 None => format!("/settings/{}", self.code.replace('.', "/")),
295 }
296 }
297
298 pub fn path(&self, admin_path: &str) -> String {
302 match &self.link {
303 Some(link) => format!("{admin_path}{link}"),
304 None => format!("{admin_path}{}", self.route()),
305 }
306 }
307}
308
309pub(crate) fn index(shell: crate::Shell) -> Response {
312 render(SettingsIndexTemplate { shell })
313}
314
315pub(crate) async fn edit_form(
318 state: &AdminState,
319 item: &SettingsItem,
320 shell: crate::Shell,
321 user: &laterite_auth::AuthenticatedUser,
322) -> Response {
323 let mut stored = match stored_value(state, item, user).await {
324 Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
325 None => return render_error(),
326 };
327 prefill_from_config(item, &mut stored, &state.app_name);
330 render(build(
331 item,
332 &state.field_types,
333 state.overrides.as_ref(),
334 None,
335 &stored,
336 &shell,
337 ))
338}
339
340pub(crate) async fn update(
342 state: &AdminState,
343 item: &SettingsItem,
344 data: HashMap<String, String>,
345 shell: crate::Shell,
346 session: &crate::session::SessionHandle,
347 user: &laterite_auth::AuthenticatedUser,
348) -> Response {
349 let value = match collect(item, &state.field_types, &data) {
352 Ok(value) => value,
353 Err(message) => {
354 let stored = stored_value(state, item, user)
355 .await
356 .flatten()
357 .unwrap_or(Value::Null);
358 return render(build(
359 item,
360 &state.field_types,
361 state.overrides.as_ref(),
362 Some(message),
363 &stored,
364 &shell,
365 ));
366 }
367 };
368 let written = if item.per_operator {
369 store::set_for(&state.db, user.user.id, &item.code, &value)
370 .await
371 .is_ok()
372 } else {
373 state.settings_store().set(&item.code, &value).await.is_ok()
374 };
375 match written {
376 true => {
377 if !item.per_operator {
381 crate::audit::record(
382 state,
383 user,
384 "backend.settings.update",
385 Some("settings"),
386 Some(item.code.as_str()),
387 None,
388 None,
389 )
390 .await;
391 }
392 session.push_flash(
393 crate::session::FlashLevel::Success,
394 if item.per_operator {
395 t!("Preferences saved.")
396 } else {
397 t!("Settings saved.")
398 },
399 );
400 Redirect::to(&item.path(&state.admin_path)).into_response()
404 }
405 false => render(build(
406 item,
407 &state.field_types,
408 state.overrides.as_ref(),
409 Some(t!("Could not save. Please try again.")),
410 &value,
411 &shell,
412 )),
413 }
414}
415
416async fn stored_value(
419 state: &AdminState,
420 item: &SettingsItem,
421 user: &laterite_auth::AuthenticatedUser,
422) -> Option<Option<Value>> {
423 if item.per_operator {
424 store::get_for(&state.db, user.user.id, &item.code)
425 .await
426 .ok()
427 } else {
428 store::get(&state.db, &item.code).await.ok()
429 }
430}
431
432fn collect(
440 item: &SettingsItem,
441 types: &crate::field::FieldRegistry,
442 data: &HashMap<String, String>,
443) -> Result<Value, Text> {
444 crate::form::value_collect(&item.fields, types, data)
445}
446
447pub const HIERARCHY: &str = "backend.settings";
450
451pub(crate) fn hierarchy() -> laterite_core::arrange::Hierarchy {
452 laterite_core::arrange::Hierarchy::new(HIERARCHY).operator_adjustable()
453}
454
455struct Placed<'a> {
456 item: &'a SettingsItem,
457 position: laterite_core::arrange::Position,
458}
459
460impl laterite_core::arrange::Arranged for Placed<'_> {
461 fn key(&self) -> &str {
462 &self.item.code
463 }
464 fn position(&self) -> &laterite_core::arrange::Position {
465 &self.position
466 }
467 fn label(&self) -> &str {
468 self.item.label.source()
469 }
470}
471
472pub(crate) fn log_arrangement(items: &[SettingsItem], order: Option<&[String]>) {
475 let placed: Vec<Placed<'_>> = items
476 .iter()
477 .map(|item| Placed {
478 item,
479 position: laterite_core::arrange::Position::new()
480 .slot(item.slot)
481 .order(item.order),
482 })
483 .collect();
484 let arranged = laterite_core::arrange::arrange(
485 &hierarchy(),
486 placed,
487 laterite_core::arrange::Overrides {
488 deployment: order,
489 operator: None,
490 },
491 )
492 .unwrap_or_default();
493 let keys: Vec<String> = arranged
494 .iter()
495 .map(|p| {
496 format!(
497 "{} ({})",
498 p.item.code,
499 laterite_core::arrange::slot::name(p.item.slot)
500 )
501 })
502 .collect();
503 tracing::info!(hierarchy = HIERARCHY, arrangement = %keys.join(" > "));
504}
505
506pub(crate) fn sidebar_groups(
511 items: &[SettingsItem],
512 admin_path: &str,
513 active_code: Option<&str>,
514 tr: &Translator,
515 icons: crate::icons::Icons<'_>,
516 order: Option<&[String]>,
517 own_order: Option<&[String]>,
518) -> Vec<CategoryView> {
519 let placed: Vec<Placed<'_>> = items
520 .iter()
521 .map(|item| Placed {
522 item,
523 position: laterite_core::arrange::Position::new()
524 .slot(item.slot)
525 .order(item.order),
526 })
527 .collect();
528 let arranged = laterite_core::arrange::arrange(
529 &hierarchy(),
530 placed,
531 laterite_core::arrange::Overrides {
532 deployment: order,
533 operator: own_order,
534 },
535 )
536 .unwrap_or_default();
537 let mut groups: Vec<CategoryView> = Vec::new();
538 for placed in arranged {
539 let i = placed.item;
540 let category = tr.t(&i.category);
541 let view = ItemView {
542 key: i.code.clone(),
543 label: tr.t(&i.label),
544 description: tr.t(&i.description),
545 path: i.path(admin_path),
546 icon: icons.render(i.icon.as_deref()),
547 active: active_code == Some(i.code.as_str()),
548 };
549 match groups.iter_mut().find(|g| g.name == category) {
550 Some(group) => group.items.push(view),
551 None => groups.push(CategoryView {
552 name: category,
553 items: vec![view],
554 }),
555 }
556 }
557 groups
558}
559
560fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
567 if item.code != brand::BrandSetting::CODE {
568 return;
569 }
570 if let Value::Object(map) = stored {
571 let blank = map
572 .get("app_name")
573 .and_then(Value::as_str)
574 .unwrap_or("")
575 .trim()
576 .is_empty();
577 if blank {
578 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
579 }
580 }
581}
582
583fn build(
584 item: &SettingsItem,
585 types: &crate::field::FieldRegistry,
586 overrides: &dyn crate::field::OverrideResolver,
587 error: Option<Text>,
588 stored: &Value,
589 shell: &crate::Shell,
590) -> SettingsFormTemplate {
591 let fields =
592 crate::form::value_views(&item.fields, types, overrides, stored, shell, &item.code);
593 SettingsFormTemplate {
594 title: shell.tt(&item.label),
595 description: shell.tt(&item.description),
596 hint: item.hint.as_ref().map(|h| shell.tt(h)),
597 action: item.path(&shell.base),
598 error: error.map(|e| shell.tt(&e)),
599 fields,
600 leave_off: !item.confirm_leave,
601 shell: shell.clone(),
602 }
603}
604
605#[derive(Clone)]
607pub(crate) struct CategoryView {
608 pub(crate) name: String,
609 pub(crate) items: Vec<ItemView>,
610}
611
612impl CategoryView {
613 pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
615 Self { name, items }
616 }
617}
618
619#[derive(Clone)]
621pub(crate) struct ItemView {
622 pub(crate) key: String,
624 pub(crate) label: String,
625 pub(crate) description: String,
626 pub(crate) path: String,
627 pub(crate) icon: String,
629 pub(crate) active: bool,
631}
632
633#[derive(Template)]
634#[template(path = "settings_index.html")]
635struct SettingsIndexTemplate {
636 shell: crate::Shell,
637}
638
639#[derive(Template)]
640#[template(path = "settings_form.html")]
641struct SettingsFormTemplate {
642 shell: crate::Shell,
643 title: String,
644 description: String,
645 hint: Option<String>,
648 action: String,
649 error: Option<String>,
650 fields: Vec<ValueFieldView>,
651 leave_off: bool,
653}
654
655#[cfg(test)]
656mod field_system_tests {
657 use super::*;
658
659 #[test]
660 #[should_panic(expected = "over the 72")]
661 fn a_lecture_of_a_description_is_refused() {
662 SettingsItem::new("acme.test", "Test", Vec::new()).description(
665 "What search engines and other crawlers may visit. The admin panel \
666 is always excluded, whatever you put here.",
667 );
668 }
669
670 #[test]
671 fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
672 let item = SettingsItem::new("acme.test", "Test", Vec::new())
673 .description("Which crawlers may visit, and what they may not.")
674 .hint(
675 "The admin panel is always excluded, whatever you put here, and a \
676 crawler obeys only the block that names it.",
677 );
678 assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
679 assert!(item.hint.is_some());
681 }
682
683 fn item(fields: Vec<FormField>) -> SettingsItem {
684 SettingsItem {
685 code: "acme.test".to_string(),
686 label: Text::new(""),
687 description: Text::new(""),
688 category: Text::new(""),
689 hint: None,
690 confirm_leave: true,
691 slot: laterite_core::arrange::slot::APPLICATION,
692 order: 0,
693 icon: None,
694 permission: None,
695 link: None,
696 fields,
697 route: None,
698 per_operator: false,
699 }
700 }
701
702 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
703 pairs
704 .iter()
705 .map(|(k, v)| (k.to_string(), v.to_string()))
706 .collect()
707 }
708
709 #[test]
712 fn settings_store_what_the_field_type_says() {
713 let cfg = item(vec![
714 FormField::text("name", "Name"),
715 FormField::switch("enabled", "Enabled"),
716 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
717 FormField::date("expires", "Expires"),
718 ]);
719 let stored = collect(
720 &cfg,
721 &crate::field::builtin_registry(),
722 &submitted(&[
723 ("name", "Acme"),
724 ("enabled", "on"),
725 ("freq", "daily"),
726 ("expires", "2026-12-31"),
727 ]),
728 )
729 .unwrap();
730
731 assert_eq!(stored["name"], "Acme");
732 assert_eq!(stored["enabled"], serde_json::json!(true));
734 assert_eq!(stored["freq"], "daily");
735 assert_eq!(stored["expires"], "2026-12-31");
736 }
737
738 #[test]
741 fn an_unticked_switch_clears() {
742 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
743 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
744 assert_eq!(stored["enabled"], serde_json::json!(false));
745 }
746
747 #[test]
750 fn a_repeater_works_in_settings() {
751 let cfg = item(vec![FormField::repeater(
752 "rules",
753 "Rules",
754 vec![
755 FormField::text("path", "Path"),
756 FormField::switch("allow", "Allow"),
757 ],
758 )]);
759 let stored = collect(
760 &cfg,
761 &crate::field::builtin_registry(),
762 &submitted(&[
763 ("rules[0][path]", "/private"),
764 ("rules[1][path]", "/public"),
765 ("rules[1][allow]", "on"),
766 ]),
767 )
768 .unwrap();
769
770 let rows = stored["rules"].as_array().unwrap();
771 assert_eq!(rows.len(), 2);
772 assert_eq!(rows[0]["path"], "/private");
773 assert_eq!(rows[0]["allow"], serde_json::json!(false));
774 assert_eq!(rows[1]["allow"], serde_json::json!(true));
775 }
776
777 #[test]
778 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
779 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
780 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
781 }
782
783 #[test]
784 fn the_form_renders_each_control_through_its_type() {
785 let cfg = item(vec![
786 FormField::switch("enabled", "Enabled"),
787 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
788 ]);
789 let html = build(
790 &cfg,
791 &crate::field::builtin_registry(),
792 &crate::field::NoOverrides,
793 None,
794 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
795 &crate::Shell::test(),
796 )
797 .render()
798 .unwrap();
799
800 assert!(html.contains(r#"type="checkbox""#));
801 assert!(html.contains(r#"name="rules[0][path]""#));
802 assert!(html.contains(r#"value="/a""#));
803 assert!(html.contains("lat-repeater__blank"));
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use laterite_core::Db;
811
812 #[test]
813 fn brand_form_prefills_app_name_from_config_when_unset() {
814 let brand = brand::settings_item();
815 let mut unset = Value::Object(Map::new());
817 prefill_from_config(&brand, &mut unset, "Configured Name");
818 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
819 let mut set = serde_json::json!({ "app_name": "Acme" });
821 prefill_from_config(&brand, &mut set, "Configured Name");
822 assert_eq!(set["app_name"], serde_json::json!("Acme"));
823 let mut other = Value::Object(Map::new());
825 prefill_from_config(&item(), &mut other, "Configured Name");
826 assert!(other.get("app_name").is_none());
827 }
828
829 fn item() -> SettingsItem {
830 SettingsItem {
831 code: "test.log".to_string(),
832 label: "Log Settings".into(),
833 description: "What the log records.".into(),
834 category: "Logs".into(),
835 hint: None,
836 confirm_leave: true,
837 slot: laterite_core::arrange::slot::APPLICATION,
838 order: 10,
839 icon: None,
840 permission: None,
841 link: None,
842 fields: vec![
843 FormField::switch("log_events", "Log events"),
844 FormField::switch("log_requests", "Log requests"),
845 FormField::text("retention_days", "Retention (days)"),
846 ],
847 route: None,
848 per_operator: false,
849 }
850 }
851
852 fn state(db: Db) -> AdminState {
853 AdminState::new(
854 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
855 db,
856 )
857 }
858
859 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
862 laterite_core::testing::connect_test(&[migrations()]).await
863 }
864
865 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
866 pairs
867 .iter()
868 .map(|(k, v)| (k.to_string(), v.to_string()))
869 .collect()
870 }
871
872 #[test]
873 fn group_orders_categories_then_items() {
874 let items = vec![
875 SettingsItem {
876 code: "b".into(),
877 label: "Beta".into(),
878 description: String::new().into(),
879 category: "System".into(),
880 hint: None,
881 confirm_leave: true,
882 slot: laterite_core::arrange::slot::APPLICATION,
883 order: 20,
884 icon: None,
885 permission: None,
886 link: None,
887 fields: vec![],
888 route: None,
889 per_operator: false,
890 },
891 SettingsItem {
892 code: "a".into(),
893 label: "Alpha".into(),
894 description: String::new().into(),
895 category: "System".into(),
896 hint: None,
897 confirm_leave: true,
898 slot: laterite_core::arrange::slot::APPLICATION,
899 order: 10,
900 icon: None,
901 permission: None,
902 link: None,
903 fields: vec![],
904 route: None,
905 per_operator: false,
906 },
907 SettingsItem {
908 code: "l".into(),
909 label: "Logs".into(),
910 description: String::new().into(),
911 category: "Logs".into(),
912 hint: None,
913 confirm_leave: true,
914 slot: laterite_core::arrange::slot::APPLICATION,
915 order: 5,
916 icon: None,
917 permission: None,
918 link: None,
919 fields: vec![],
920 route: None,
921 per_operator: false,
922 },
923 ];
924 let groups = sidebar_groups(
925 &items,
926 "/admin",
927 None,
928 &Translator::new("en"),
929 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
930 None,
931 None,
932 );
933 assert_eq!(groups[0].name, "Logs");
935 assert_eq!(groups[1].name, "System");
936 assert_eq!(groups[1].items[0].label, "Alpha");
938 assert_eq!(groups[1].items[1].label, "Beta");
939 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
941 }
942
943 #[test]
944 fn group_marks_only_the_active_item() {
945 let items = vec![item()];
946 let groups = sidebar_groups(
947 &items,
948 "/admin",
949 Some("test.log"),
950 &Translator::new("en"),
951 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
952 None,
953 None,
954 );
955 let active: Vec<&str> = groups
956 .iter()
957 .flat_map(|g| &g.items)
958 .filter(|i| i.active)
959 .map(|i| i.label.as_str())
960 .collect();
961 assert_eq!(active, ["Log Settings"]);
962 }
963
964 #[test]
965 fn settings_model_item_path_is_its_form() {
966 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
969 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
971
972 let mut resolved = item();
974 resolved.mount_at("/acme/logging/log");
975 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
976 }
977
978 #[test]
979 fn link_item_path_follows_the_link() {
980 let admins = crate::builtin_settings()
981 .into_iter()
982 .find(|i| i.code == "backend.administrators")
983 .unwrap();
984 assert_eq!(admins.category.source(), "Users");
985 assert!(admins.link.is_some());
986 assert!(admins.fields.is_empty());
987 assert_eq!(admins.path("/admin"), "/admin/users");
989 assert!(crate::builtin_settings()
990 .iter()
991 .any(|i| i.code == "backend.roles"));
992 }
993
994 #[tokio::test]
995 async fn update_persists_typed_values() {
996 let (db, _guard) = test_db().await;
997 let st = state(db.clone());
998
999 let it = item();
1000 let resp = update(
1001 &st,
1002 &it,
1003 data(&[("log_events", "on"), ("retention_days", "30")]),
1005 crate::Shell::test(),
1006 &crate::session::SessionHandle::from_blob(None),
1007 &crate::audit::test_actor(),
1008 )
1009 .await;
1010 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
1011
1012 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
1013 assert_eq!(stored["log_events"], serde_json::json!(true));
1014 assert_eq!(stored["log_requests"], serde_json::json!(false));
1015 assert_eq!(stored["retention_days"], serde_json::json!("30"));
1016 }
1017
1018 #[test]
1019 fn index_renders() {
1020 let resp = index(crate::Shell::test());
1021 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1022 }
1023
1024 #[tokio::test]
1025 async fn edit_form_renders_for_unset_item() {
1026 let (db, _guard) = test_db().await;
1027 let st = state(db);
1028 let it = item();
1030 let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
1031 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1032 }
1033}