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}
37
38use std::collections::HashMap;
39
40use askama::Template;
41use axum::response::{IntoResponse, Redirect, Response};
42use laterite_core::validation::Mode;
43use laterite_core::{t, Text, Translator};
44use serde::Serialize;
45use serde_json::{Map, Value};
46
47use crate::{render, render_error, AdminState};
48
49pub use crate::form::FormField;
56
57#[derive(Debug, Clone, Serialize)]
60#[non_exhaustive]
61pub struct SettingsItem {
62 pub code: String,
70 pub label: Text,
72 pub description: Text,
73 pub category: Text,
75 pub hint: Option<Text>,
79 pub confirm_leave: bool,
81 pub order: i32,
83 pub icon: Option<String>,
86 pub permission: Option<String>,
89 pub link: Option<String>,
93 pub fields: Vec<FormField>,
94 pub(crate) route: Option<String>,
97 pub per_operator: bool,
100}
101
102pub(crate) const MAX_DESCRIPTION: usize = 72;
107
108impl SettingsItem {
109 pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
115 Self {
116 code: code.into(),
117 label: label.into(),
118 description: Text::from(""),
119 category: Text::from(""),
120 hint: None,
121 confirm_leave: true,
122 order: 0,
123 icon: None,
124 permission: None,
125 link: None,
126 fields,
127 route: None,
128 per_operator: false,
129 }
130 }
131
132 pub fn per_operator(mut self) -> Self {
135 self.per_operator = true;
136 self
137 }
138
139 pub fn description(mut self, text: impl Into<Text>) -> Self {
146 let text = text.into();
147 debug_assert!(
148 text.source().chars().count() <= MAX_DESCRIPTION,
149 "settings description is {} characters, over the {MAX_DESCRIPTION} that \
150 fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
151 text.source().chars().count(),
152 text.source(),
153 );
154 self.description = text;
155 self
156 }
157
158 pub fn hint(mut self, text: impl Into<Text>) -> Self {
161 self.hint = Some(text.into());
162 self
163 }
164
165 pub fn confirm_leave(mut self, ask: bool) -> Self {
168 self.confirm_leave = ask;
169 self
170 }
171
172 pub fn category(mut self, text: impl Into<Text>) -> Self {
174 self.category = text.into();
175 self
176 }
177
178 pub fn order(mut self, order: i32) -> Self {
180 self.order = order;
181 self
182 }
183
184 pub fn icon(mut self, icon: impl Into<String>) -> Self {
186 self.icon = Some(icon.into());
187 self
188 }
189
190 pub fn permission(mut self, permission: impl Into<String>) -> Self {
192 self.permission = Some(permission.into());
193 self
194 }
195
196 pub fn link(mut self, link: impl Into<String>) -> Self {
199 self.link = Some(link.into());
200 self
201 }
202
203 pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
206 self.route = Some(route.into());
207 }
208
209 pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
216 self.route = Some(route.into());
217 self
218 }
219
220 pub(crate) fn route(&self) -> String {
227 match &self.route {
228 Some(route) => route.clone(),
229 None if self.per_operator => format!("/preferences/{}", self.code.replace('.', "/")),
230 None => format!("/settings/{}", self.code.replace('.', "/")),
231 }
232 }
233
234 pub fn path(&self, admin_path: &str) -> String {
238 match &self.link {
239 Some(link) => format!("{admin_path}{link}"),
240 None => format!("{admin_path}{}", self.route()),
241 }
242 }
243}
244
245pub(crate) fn index(shell: crate::Shell) -> Response {
248 render(SettingsIndexTemplate { shell })
249}
250
251pub(crate) async fn edit_form(
254 state: &AdminState,
255 item: &SettingsItem,
256 shell: crate::Shell,
257 user: &laterite_auth::AuthenticatedUser,
258) -> Response {
259 let mut stored = match stored_value(state, item, user).await {
260 Some(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
261 None => return render_error(),
262 };
263 prefill_from_config(item, &mut stored, &state.app_name);
266 render(build(
267 item,
268 &state.field_types,
269 state.overrides.as_ref(),
270 None,
271 &stored,
272 &shell,
273 ))
274}
275
276pub(crate) async fn update(
278 state: &AdminState,
279 item: &SettingsItem,
280 data: HashMap<String, String>,
281 shell: crate::Shell,
282 session: &crate::session::SessionHandle,
283 user: &laterite_auth::AuthenticatedUser,
284) -> Response {
285 let value = match collect(item, &state.field_types, &data) {
288 Ok(value) => value,
289 Err(message) => {
290 let stored = stored_value(state, item, user)
291 .await
292 .flatten()
293 .unwrap_or(Value::Null);
294 return render(build(
295 item,
296 &state.field_types,
297 state.overrides.as_ref(),
298 Some(message),
299 &stored,
300 &shell,
301 ));
302 }
303 };
304 let written = if item.per_operator {
305 store::set_for(&state.db, user.user.id, &item.code, &value)
306 .await
307 .is_ok()
308 } else {
309 store::set(&state.db, &item.code, &value).await.is_ok()
310 };
311 match written {
312 true => {
313 if item.code == brand::BrandSetting::CODE {
316 state.invalidate_brand();
317 }
318 if !item.per_operator {
322 crate::audit::record(
323 state,
324 user,
325 "backend.settings.update",
326 Some("settings"),
327 Some(item.code.as_str()),
328 None,
329 None,
330 )
331 .await;
332 }
333 session.push_flash(
334 crate::session::FlashLevel::Success,
335 if item.per_operator {
336 t!("Preferences saved.")
337 } else {
338 t!("Settings saved.")
339 },
340 );
341 Redirect::to(&item.path(&state.admin_path)).into_response()
345 }
346 false => render(build(
347 item,
348 &state.field_types,
349 state.overrides.as_ref(),
350 Some(t!("Could not save. Please try again.")),
351 &value,
352 &shell,
353 )),
354 }
355}
356
357async fn stored_value(
360 state: &AdminState,
361 item: &SettingsItem,
362 user: &laterite_auth::AuthenticatedUser,
363) -> Option<Option<Value>> {
364 if item.per_operator {
365 store::get_for(&state.db, user.user.id, &item.code)
366 .await
367 .ok()
368 } else {
369 store::get(&state.db, &item.code).await.ok()
370 }
371}
372
373fn collect(
381 item: &SettingsItem,
382 types: &crate::field::FieldRegistry,
383 data: &HashMap<String, String>,
384) -> Result<Value, Text> {
385 let mut object = Map::new();
386 for field in &item.fields {
387 let Some(field_type) = types.get(&field.field_type) else {
388 return Err(t!("This screen uses a field type that is not registered."));
389 };
390 let opts = field_type
391 .resolve_options(&field.options, types)
392 .map_err(|_| t!("This screen has a malformed field."))?;
393 let submitted = crate::field::SubmittedField::new(&field.name, data);
394 let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
397 Ok(Some(value)) => attr_to_json(value),
398 Ok(None) => Value::String(String::new()),
399 Err(_) => return Err(t!("Check the values and try again.")),
400 };
401 object.insert(field.name.clone(), value);
402 }
403 Ok(Value::Object(object))
404}
405
406fn attr_to_json(value: laterite_core::AttrValue) -> Value {
408 use laterite_core::AttrValue;
409 match value {
410 AttrValue::Null => Value::String(String::new()),
411 AttrValue::Bool(b) => Value::Bool(b),
412 AttrValue::Int(i) => Value::from(i),
413 AttrValue::Float(f) => Value::from(f),
414 AttrValue::Json(j) => j,
415 other => Value::String(other.to_text().unwrap_or_default()),
416 }
417}
418
419pub(crate) fn sidebar_groups(
425 items: &[SettingsItem],
426 admin_path: &str,
427 active_code: Option<&str>,
428 tr: &Translator,
429 icons: crate::icons::Icons<'_>,
430) -> Vec<CategoryView> {
431 let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
433 for item in items {
434 by_category
435 .entry(tr.t(&item.category))
436 .or_default()
437 .push(item);
438 }
439 let mut groups: Vec<CategoryView> = by_category
440 .into_iter()
441 .map(|(category, mut items)| {
442 items.sort_by(|a, b| {
444 a.order
445 .cmp(&b.order)
446 .then_with(|| a.label.source().cmp(b.label.source()))
447 });
448 CategoryView {
449 min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
450 name: category,
451 items: items
452 .iter()
453 .map(|i| ItemView {
454 label: tr.t(&i.label),
455 description: tr.t(&i.description),
456 path: i.path(admin_path),
457 icon: icons.render(i.icon.as_deref()),
458 active: active_code == Some(i.code.as_str()),
459 })
460 .collect(),
461 }
462 })
463 .collect();
464 groups.sort_by(|a, b| {
465 a.min_order
466 .cmp(&b.min_order)
467 .then_with(|| a.name.cmp(&b.name))
468 });
469 groups
470}
471
472fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
479 if item.code != brand::BrandSetting::CODE {
480 return;
481 }
482 if let Value::Object(map) = stored {
483 let blank = map
484 .get("app_name")
485 .and_then(Value::as_str)
486 .unwrap_or("")
487 .trim()
488 .is_empty();
489 if blank {
490 map.insert("app_name".to_string(), Value::String(app_name.to_string()));
491 }
492 }
493}
494
495fn build(
496 item: &SettingsItem,
497 types: &crate::field::FieldRegistry,
498 overrides: &dyn crate::field::OverrideResolver,
499 error: Option<Text>,
500 stored: &Value,
501 shell: &crate::Shell,
502) -> SettingsFormTemplate {
503 let fields = item
504 .fields
505 .iter()
506 .map(|f| {
507 let label = shell.tt(&f.label);
508 let control = match types.get(&f.field_type) {
509 Some(ft) => {
510 let opts = ft
511 .resolve_options(&f.options, types)
512 .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
513 let value = match stored.get(&f.name) {
514 Some(Value::Array(rows)) => {
515 crate::field::FieldValue::Json(Value::Array(rows.clone()))
516 }
517 Some(Value::String(text)) => {
518 crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
519 }
520 Some(Value::Null) | None => {
521 crate::field::FieldValue::Text(ft.to_control(None, &opts))
522 }
523 Some(other) => crate::field::FieldValue::Text(
524 ft.to_control(Some(&other.to_string()), &opts),
525 ),
526 };
527 let cx = crate::field::FieldCx {
528 name: &f.name,
529 id: &f.name,
530 label: &label,
531 value: &value,
532 required: false,
533 max_length: crate::form::max_length(&f.rules),
534 opts: &opts,
535 base: &shell.base,
536 i18n: shell.i18n(),
537 };
538 let scope = crate::field::OverrideScope {
539 surface: crate::field::Surface::Field,
540 view_key: &f.field_type,
541 resource: Some(&item.code),
542 field: Some(&f.name),
543 };
544 crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
545 }
546 None => String::new(),
547 };
548 FieldView {
549 label,
550 help: f.help.as_ref().map(|h| shell.tt(h)),
551 control,
552 layout: crate::form::layout_classes(f.span, f.break_row),
553 attrs: crate::form::behaviour_attrs(f),
554 }
555 })
556 .collect();
557 SettingsFormTemplate {
558 title: shell.tt(&item.label),
559 description: shell.tt(&item.description),
560 hint: item.hint.as_ref().map(|h| shell.tt(h)),
561 action: item.path(&shell.base),
562 error: error.map(|e| shell.tt(&e)),
563 fields,
564 leave_off: !item.confirm_leave,
565 shell: shell.clone(),
566 }
567}
568
569#[derive(Clone)]
571pub(crate) struct CategoryView {
572 pub(crate) name: String,
573 min_order: i32,
574 pub(crate) items: Vec<ItemView>,
575}
576
577impl CategoryView {
578 pub(crate) fn new(name: String, items: Vec<ItemView>) -> Self {
580 Self {
581 name,
582 min_order: 0,
583 items,
584 }
585 }
586}
587
588#[derive(Clone)]
590pub(crate) struct ItemView {
591 pub(crate) label: String,
592 pub(crate) description: String,
593 pub(crate) path: String,
594 pub(crate) icon: String,
596 pub(crate) active: bool,
598}
599
600#[derive(Template)]
601#[template(path = "settings_index.html")]
602struct SettingsIndexTemplate {
603 shell: crate::Shell,
604}
605
606struct FieldView {
607 label: String,
608 help: Option<String>,
609 control: String,
611 layout: String,
613 attrs: String,
615}
616
617#[derive(Template)]
618#[template(path = "settings_form.html")]
619struct SettingsFormTemplate {
620 shell: crate::Shell,
621 title: String,
622 description: String,
623 hint: Option<String>,
626 action: String,
627 error: Option<String>,
628 fields: Vec<FieldView>,
629 leave_off: bool,
631}
632
633#[cfg(test)]
634mod field_system_tests {
635 use super::*;
636
637 #[test]
638 #[should_panic(expected = "over the 72")]
639 fn a_lecture_of_a_description_is_refused() {
640 SettingsItem::new("acme.test", "Test", Vec::new()).description(
643 "What search engines and other crawlers may visit. The admin panel \
644 is always excluded, whatever you put here.",
645 );
646 }
647
648 #[test]
649 fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
650 let item = SettingsItem::new("acme.test", "Test", Vec::new())
651 .description("Which crawlers may visit, and what they may not.")
652 .hint(
653 "The admin panel is always excluded, whatever you put here, and a \
654 crawler obeys only the block that names it.",
655 );
656 assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
657 assert!(item.hint.is_some());
659 }
660
661 fn item(fields: Vec<FormField>) -> SettingsItem {
662 SettingsItem {
663 code: "acme.test".to_string(),
664 label: Text::new(""),
665 description: Text::new(""),
666 category: Text::new(""),
667 hint: None,
668 confirm_leave: true,
669 order: 0,
670 icon: None,
671 permission: None,
672 link: None,
673 fields,
674 route: None,
675 per_operator: false,
676 }
677 }
678
679 fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
680 pairs
681 .iter()
682 .map(|(k, v)| (k.to_string(), v.to_string()))
683 .collect()
684 }
685
686 #[test]
689 fn settings_store_what_the_field_type_says() {
690 let cfg = item(vec![
691 FormField::text("name", "Name"),
692 FormField::switch("enabled", "Enabled"),
693 FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
694 FormField::date("expires", "Expires"),
695 ]);
696 let stored = collect(
697 &cfg,
698 &crate::field::builtin_registry(),
699 &submitted(&[
700 ("name", "Acme"),
701 ("enabled", "on"),
702 ("freq", "daily"),
703 ("expires", "2026-12-31"),
704 ]),
705 )
706 .unwrap();
707
708 assert_eq!(stored["name"], "Acme");
709 assert_eq!(stored["enabled"], serde_json::json!(true));
711 assert_eq!(stored["freq"], "daily");
712 assert_eq!(stored["expires"], "2026-12-31");
713 }
714
715 #[test]
718 fn an_unticked_switch_clears() {
719 let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
720 let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
721 assert_eq!(stored["enabled"], serde_json::json!(false));
722 }
723
724 #[test]
727 fn a_repeater_works_in_settings() {
728 let cfg = item(vec![FormField::repeater(
729 "rules",
730 "Rules",
731 vec![
732 FormField::text("path", "Path"),
733 FormField::switch("allow", "Allow"),
734 ],
735 )]);
736 let stored = collect(
737 &cfg,
738 &crate::field::builtin_registry(),
739 &submitted(&[
740 ("rules[0][path]", "/private"),
741 ("rules[1][path]", "/public"),
742 ("rules[1][allow]", "on"),
743 ]),
744 )
745 .unwrap();
746
747 let rows = stored["rules"].as_array().unwrap();
748 assert_eq!(rows.len(), 2);
749 assert_eq!(rows[0]["path"], "/private");
750 assert_eq!(rows[0]["allow"], serde_json::json!(false));
751 assert_eq!(rows[1]["allow"], serde_json::json!(true));
752 }
753
754 #[test]
755 fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
756 let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
757 assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
758 }
759
760 #[test]
761 fn the_form_renders_each_control_through_its_type() {
762 let cfg = item(vec![
763 FormField::switch("enabled", "Enabled"),
764 FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
765 ]);
766 let html = build(
767 &cfg,
768 &crate::field::builtin_registry(),
769 &crate::field::NoOverrides,
770 None,
771 &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
772 &crate::Shell::test(),
773 )
774 .render()
775 .unwrap();
776
777 assert!(html.contains(r#"type="checkbox""#));
778 assert!(html.contains(r#"name="rules[0][path]""#));
779 assert!(html.contains(r#"value="/a""#));
780 assert!(html.contains("lat-repeater__blank"));
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787 use laterite_core::Db;
788
789 #[test]
790 fn brand_form_prefills_app_name_from_config_when_unset() {
791 let brand = brand::settings_item();
792 let mut unset = Value::Object(Map::new());
794 prefill_from_config(&brand, &mut unset, "Configured Name");
795 assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
796 let mut set = serde_json::json!({ "app_name": "Acme" });
798 prefill_from_config(&brand, &mut set, "Configured Name");
799 assert_eq!(set["app_name"], serde_json::json!("Acme"));
800 let mut other = Value::Object(Map::new());
802 prefill_from_config(&item(), &mut other, "Configured Name");
803 assert!(other.get("app_name").is_none());
804 }
805
806 fn item() -> SettingsItem {
807 SettingsItem {
808 code: "test.log".to_string(),
809 label: "Log Settings".into(),
810 description: "What the log records.".into(),
811 category: "Logs".into(),
812 hint: None,
813 confirm_leave: true,
814 order: 10,
815 icon: None,
816 permission: None,
817 link: None,
818 fields: vec![
819 FormField::switch("log_events", "Log events"),
820 FormField::switch("log_requests", "Log requests"),
821 FormField::text("retention_days", "Retention (days)"),
822 ],
823 route: None,
824 per_operator: false,
825 }
826 }
827
828 fn state(db: Db) -> AdminState {
829 AdminState::new(
830 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
831 db,
832 )
833 }
834
835 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
838 laterite_core::testing::connect_test(&[migrations()]).await
839 }
840
841 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
842 pairs
843 .iter()
844 .map(|(k, v)| (k.to_string(), v.to_string()))
845 .collect()
846 }
847
848 #[test]
849 fn group_orders_categories_then_items() {
850 let items = vec![
851 SettingsItem {
852 code: "b".into(),
853 label: "Beta".into(),
854 description: String::new().into(),
855 category: "System".into(),
856 hint: None,
857 confirm_leave: true,
858 order: 20,
859 icon: None,
860 permission: None,
861 link: None,
862 fields: vec![],
863 route: None,
864 per_operator: false,
865 },
866 SettingsItem {
867 code: "a".into(),
868 label: "Alpha".into(),
869 description: String::new().into(),
870 category: "System".into(),
871 hint: None,
872 confirm_leave: true,
873 order: 10,
874 icon: None,
875 permission: None,
876 link: None,
877 fields: vec![],
878 route: None,
879 per_operator: false,
880 },
881 SettingsItem {
882 code: "l".into(),
883 label: "Logs".into(),
884 description: String::new().into(),
885 category: "Logs".into(),
886 hint: None,
887 confirm_leave: true,
888 order: 5,
889 icon: None,
890 permission: None,
891 link: None,
892 fields: vec![],
893 route: None,
894 per_operator: false,
895 },
896 ];
897 let groups = sidebar_groups(
898 &items,
899 "/admin",
900 None,
901 &Translator::new("en"),
902 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
903 );
904 assert_eq!(groups[0].name, "Logs");
906 assert_eq!(groups[1].name, "System");
907 assert_eq!(groups[1].items[0].label, "Alpha");
909 assert_eq!(groups[1].items[1].label, "Beta");
910 assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
912 }
913
914 #[test]
915 fn group_marks_only_the_active_item() {
916 let items = vec![item()];
917 let groups = sidebar_groups(
918 &items,
919 "/admin",
920 Some("test.log"),
921 &Translator::new("en"),
922 crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
923 );
924 let active: Vec<&str> = groups
925 .iter()
926 .flat_map(|g| &g.items)
927 .filter(|i| i.active)
928 .map(|i| i.label.as_str())
929 .collect();
930 assert_eq!(active, ["Log Settings"]);
931 }
932
933 #[test]
934 fn settings_model_item_path_is_its_form() {
935 assert_eq!(item().path("/admin"), "/admin/settings/test/log");
938 assert_eq!(item().path("/manage"), "/manage/settings/test/log");
940
941 let mut resolved = item();
943 resolved.mount_at("/acme/logging/log");
944 assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
945 }
946
947 #[test]
948 fn link_item_path_follows_the_link() {
949 let admins = crate::builtin_settings()
950 .into_iter()
951 .find(|i| i.code == "backend.administrators")
952 .unwrap();
953 assert_eq!(admins.category.source(), "Users");
954 assert!(admins.link.is_some());
955 assert!(admins.fields.is_empty());
956 assert_eq!(admins.path("/admin"), "/admin/users");
958 assert!(crate::builtin_settings()
959 .iter()
960 .any(|i| i.code == "backend.roles"));
961 }
962
963 #[tokio::test]
964 async fn update_persists_typed_values() {
965 let (db, _guard) = test_db().await;
966 let st = state(db.clone());
967
968 let it = item();
969 let resp = update(
970 &st,
971 &it,
972 data(&[("log_events", "on"), ("retention_days", "30")]),
974 crate::Shell::test(),
975 &crate::session::SessionHandle::from_blob(None),
976 &crate::audit::test_actor(),
977 )
978 .await;
979 assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
980
981 let stored = store::get(&db, "test.log").await.unwrap().unwrap();
982 assert_eq!(stored["log_events"], serde_json::json!(true));
983 assert_eq!(stored["log_requests"], serde_json::json!(false));
984 assert_eq!(stored["retention_days"], serde_json::json!("30"));
985 }
986
987 #[test]
988 fn index_renders() {
989 let resp = index(crate::Shell::test());
990 assert_eq!(resp.status(), axum::http::StatusCode::OK);
991 }
992
993 #[tokio::test]
994 async fn edit_form_renders_for_unset_item() {
995 let (db, _guard) = test_db().await;
996 let st = state(db);
997 let it = item();
999 let resp = edit_form(&st, &it, crate::Shell::test(), &crate::audit::test_actor()).await;
1000 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1001 }
1002}