1use std::collections::HashMap;
22use std::sync::Arc;
23
24use askama::Template;
25use axum::http::StatusCode;
26use axum::response::{IntoResponse, Redirect, Response};
27use laterite_core::query::{bind_values, build as to_sql, text_cast};
28use laterite_core::validation::{validate, FieldRules, Mode, Rule};
29use laterite_core::{t, AnyRowExt, ErrorBag, Text};
30use sea_query::{Alias, Expr, Query};
31use serde::{Deserialize, Serialize};
32
33use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
34use crate::persist::{DefaultPersister, Persister, PersisterRegistry, SaveError};
35use crate::sql::valid_ident;
36use crate::{not_found, render, render_error, AdminState};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FormField {
44 pub name: String,
45 pub label: Text,
47 #[serde(rename = "type")]
50 pub field_type: String,
51 #[serde(default)]
53 pub options: serde_json::Value,
54 #[serde(default)]
56 pub rules: Vec<Rule>,
57 #[serde(default)]
61 pub translatable: bool,
62}
63
64impl FormField {
65 pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
67 Self {
68 name: name.to_string(),
69 label: label.into(),
70 field_type: field_type.to_string(),
71 options: serde_json::Value::Null,
72 rules: Vec::new(),
73 translatable: false,
74 }
75 }
76
77 pub fn text(name: &str, label: impl Into<Text>) -> Self {
78 Self::of(name, label, "text")
79 }
80
81 pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
82 Self::of(name, label, "textarea")
83 }
84
85 pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
89 Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
90 }
91
92 pub fn options(mut self, options: serde_json::Value) -> Self {
94 self.options = options;
95 self
96 }
97
98 pub fn required(mut self) -> Self {
100 self.rules.push(Rule::Required);
101 self
102 }
103
104 pub fn required_on(mut self, mode: Mode) -> Self {
107 self.rules.push(Rule::RequiredOn(mode));
108 self
109 }
110
111 pub fn unique(mut self) -> Self {
114 self.rules.push(Rule::Unique);
115 self
116 }
117
118 pub fn max_length(mut self, n: usize) -> Self {
120 self.rules.push(Rule::MaxLength(n));
121 self
122 }
123
124 pub fn min_length(mut self, n: usize) -> Self {
126 self.rules.push(Rule::MinLength(n));
127 self
128 }
129
130 pub fn email(mut self) -> Self {
132 self.rules.push(Rule::Email);
133 self
134 }
135
136 pub fn translatable(mut self) -> Self {
138 self.translatable = true;
139 self
140 }
141}
142
143#[derive(Debug, Clone, Serialize)]
146pub struct FormConfig {
147 pub entity: String,
148 pub title: Text,
150 pub base_path: String,
151 pub id_field: String,
152 pub fields: Vec<FormField>,
153 pub persist: Option<String>,
156}
157
158impl FormConfig {
159 fn idents_valid(&self) -> bool {
160 valid_ident(&self.entity)
161 && valid_ident(&self.id_field)
162 && self.fields.iter().all(|f| valid_ident(&f.name))
163 }
164}
165
166pub(crate) struct PreparedForm {
171 config: FormConfig,
172 fields: Vec<PreparedField>,
174 persister: Arc<dyn Persister>,
176}
177
178struct PreparedField {
181 opts: ResolvedOptions,
182 rules: Vec<Rule>,
183}
184
185impl PreparedForm {
186 pub(crate) fn prepare(
190 config: FormConfig,
191 field_types: &crate::field::FieldRegistry,
192 persisters: &PersisterRegistry,
193 ) -> Result<Self, String> {
194 let mut fields = Vec::with_capacity(config.fields.len());
195 for f in &config.fields {
196 let ft = field_types.get(&f.field_type).ok_or_else(|| {
197 format!(
198 "field `{}` uses unregistered type `{}`",
199 f.name, f.field_type
200 )
201 })?;
202 let opts = ft
203 .resolve_options(&f.options)
204 .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
205 let mut rules = ft.intrinsic_rules(&opts);
206 rules.extend(f.rules.clone());
207 fields.push(PreparedField { opts, rules });
208 }
209 let persister: Arc<dyn Persister> = match &config.persist {
210 Some(key) => persisters
211 .get(key)
212 .cloned()
213 .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
214 None => Arc::new(DefaultPersister::from_config(&config)),
215 };
216 Ok(Self {
217 config,
218 fields,
219 persister,
220 })
221 }
222}
223
224fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
226 form.config
227 .fields
228 .iter()
229 .zip(&form.fields)
230 .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
233 .collect()
234}
235
236fn has_required(rules: &[Rule]) -> bool {
238 rules
239 .iter()
240 .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
241}
242
243pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
245 render(build(
246 state,
247 form,
248 &format!("{}/new", form.config.base_path),
249 None,
250 &HashMap::new(),
251 &ErrorBag::default(),
252 &shell,
253 ))
254}
255
256pub(crate) async fn create(
259 state: &AdminState,
260 form: &PreparedForm,
261 data: HashMap<String, String>,
262 shell: crate::Shell,
263 user: &laterite_auth::AuthenticatedUser,
264) -> Response {
265 if !form.config.idents_valid() {
266 return render_error();
267 }
268 let action = format!("{}/new", form.config.base_path);
269
270 let bag = match validate(
271 &state.db,
272 &form.config.entity,
273 &form.config.id_field,
274 &merged_field_rules(form),
275 &data,
276 Mode::Create,
277 None,
278 )
279 .await
280 {
281 Ok(bag) => bag,
282 Err(_) => return render_error(),
283 };
284 if !bag.is_empty() {
285 return (
288 StatusCode::UNPROCESSABLE_ENTITY,
289 render(build(state, form, &action, None, &data, &bag, &shell)),
290 )
291 .into_response();
292 }
293
294 match form.persister.create(&state.db, &data).await {
295 Ok(new_id) => {
296 let audit_action = format!("backend.{}.create", form.config.entity);
297 let target_id = new_id.to_string();
298 crate::audit::record(
299 state,
300 user,
301 &audit_action,
302 Some(form.config.entity.as_str()),
303 Some(target_id.as_str()),
304 None,
305 )
306 .await;
307 Redirect::to(&form.config.base_path).into_response()
308 }
309 Err(SaveError::Invalid(bag)) => (
311 StatusCode::UNPROCESSABLE_ENTITY,
312 render(build(state, form, &action, None, &data, &bag, &shell)),
313 )
314 .into_response(),
315 Err(SaveError::Failed(msg)) => {
316 tracing::error!(error = %msg, "admin create failed");
317 render(build(
318 state,
319 form,
320 &action,
321 Some(t!("Could not save. Check the values and try again.")),
322 &data,
323 &ErrorBag::default(),
324 &shell,
325 ))
326 }
327 }
328}
329
330pub(crate) async fn edit_form(
332 state: &AdminState,
333 form: &PreparedForm,
334 id: String,
335 shell: crate::Shell,
336) -> Response {
337 if !form.config.idents_valid() {
338 return render_error();
339 }
340 let (sql, values) = {
344 let cast = text_cast(state.db.backend);
345 let mut select = Query::select();
346 for field in &form.config.fields {
347 select.expr_as(
348 Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
349 Alias::new(&field.name),
350 );
351 }
352 select.from(Alias::new(&form.config.entity)).and_where(
353 Expr::col(Alias::new(&form.config.id_field))
354 .cast_as(Alias::new(cast))
355 .eq(id.clone()),
356 );
357 to_sql(state.db.backend, select)
358 };
359 let row = match bind_values(sqlx::query(&sql), values)
360 .fetch_optional(&state.db.pool)
361 .await
362 {
363 Ok(row) => row,
364 Err(_) => return render_error(),
365 };
366 let Some(row) = row else {
367 return not_found();
368 };
369
370 let values = form
371 .config
372 .fields
373 .iter()
374 .map(|f| {
375 let value = row
376 .get_text_opt(f.name.as_str())
377 .ok()
378 .flatten()
379 .unwrap_or_default();
380 (f.name.clone(), value)
381 })
382 .collect();
383
384 render(build(
385 state,
386 form,
387 &format!("{}/{}/edit", form.config.base_path, id),
388 None,
389 &values,
390 &ErrorBag::default(),
391 &shell,
392 ))
393}
394
395pub(crate) async fn update(
398 state: &AdminState,
399 form: &PreparedForm,
400 id: String,
401 data: HashMap<String, String>,
402 shell: crate::Shell,
403 user: &laterite_auth::AuthenticatedUser,
404) -> Response {
405 if !form.config.idents_valid() {
406 return render_error();
407 }
408 let action = format!("{}/{}/edit", form.config.base_path, id);
409
410 let bag = match validate(
411 &state.db,
412 &form.config.entity,
413 &form.config.id_field,
414 &merged_field_rules(form),
415 &data,
416 Mode::Update,
417 Some(&id),
418 )
419 .await
420 {
421 Ok(bag) => bag,
422 Err(_) => return render_error(),
423 };
424 if !bag.is_empty() {
425 return (
428 StatusCode::UNPROCESSABLE_ENTITY,
429 render(build(state, form, &action, None, &data, &bag, &shell)),
430 )
431 .into_response();
432 }
433
434 match form.persister.update(&state.db, &id, &data).await {
435 Ok(()) => {
436 let audit_action = format!("backend.{}.update", form.config.entity);
437 crate::audit::record(
438 state,
439 user,
440 &audit_action,
441 Some(form.config.entity.as_str()),
442 Some(id.as_str()),
443 None,
444 )
445 .await;
446 Redirect::to(&form.config.base_path).into_response()
447 }
448 Err(SaveError::Invalid(bag)) => (
449 StatusCode::UNPROCESSABLE_ENTITY,
450 render(build(state, form, &action, None, &data, &bag, &shell)),
451 )
452 .into_response(),
453 Err(SaveError::Failed(msg)) => {
454 tracing::error!(error = %msg, "admin update failed");
455 render(build(
456 state,
457 form,
458 &action,
459 Some(t!("Could not save. Check the values and try again.")),
460 &data,
461 &ErrorBag::default(),
462 &shell,
463 ))
464 }
465 }
466}
467
468fn build(
469 state: &AdminState,
470 form: &PreparedForm,
471 action: &str,
472 error: Option<Text>,
473 values: &HashMap<String, String>,
474 bag: &ErrorBag,
475 shell: &crate::Shell,
476) -> FormTemplate {
477 let fields = form
478 .config
479 .fields
480 .iter()
481 .zip(&form.fields)
482 .map(|(f, pf)| {
483 let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
484 let required = has_required(&pf.rules);
485 let label = shell.tt(&f.label);
488 let cx = FieldCx {
489 name: &f.name,
490 id: &f.name,
491 label: &label,
492 value: &value,
493 required,
494 opts: &pf.opts,
495 base: &shell.base,
496 };
497 let control = match state.field_types.get(&f.field_type) {
500 Some(ft) => {
501 let scope = OverrideScope {
502 surface: Surface::Field,
503 view_key: &f.field_type,
504 resource: Some(&form.config.base_path),
505 field: Some(&f.name),
506 };
507 render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
508 }
509 None => String::new(),
510 };
511 FieldView {
512 id: f.name.clone(),
513 label,
514 control,
515 required,
516 errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
518 }
519 })
520 .collect();
521 let keys: Vec<&str> = form
525 .config
526 .fields
527 .iter()
528 .zip(&form.fields)
529 .filter_map(|(f, pf)| {
530 state
531 .field_types
532 .get(&f.field_type)
533 .map(|ft| ft.assets(&pf.opts))
534 })
535 .flatten()
536 .collect();
537 let shell_title = shell.tt(&form.config.title);
539 let error = error.map(|m| shell.tt(&m));
540 let mut shell = shell.clone();
541 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
542 FormTemplate {
543 shell,
544 title: shell_title,
545 action: action.to_string(),
546 cancel_path: form.config.base_path.clone(),
547 error,
548 fields,
549 }
550}
551
552struct FieldView {
553 id: String,
555 label: String,
556 control: String,
559 required: bool,
560 errors: Vec<String>,
561}
562
563#[derive(Template)]
564#[template(path = "form.html")]
565struct FormTemplate {
566 shell: crate::Shell,
567 title: String,
568 action: String,
569 cancel_path: String,
570 error: Option<String>,
571 fields: Vec<FieldView>,
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use axum::http::StatusCode;
578 use laterite_core::strata::{
579 async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
580 };
581 use laterite_core::testing::{connect_test, TestGuard};
582 use laterite_core::Db;
583
584 struct CreateSamples;
587 #[async_trait(?Send)]
588 impl Migration for CreateSamples {
589 fn name(&self) -> &str {
590 "0001_create_samples"
591 }
592 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
593 s.exec(
594 Table::create()
595 .table(Alias::new("samples"))
596 .if_not_exists()
597 .col(
598 ColumnDef::new(Alias::new("id"))
599 .big_integer()
600 .not_null()
601 .auto_increment()
602 .primary_key(),
603 )
604 .col(ColumnDef::new(Alias::new("code")).text().not_null())
605 .col(ColumnDef::new(Alias::new("name")).text().not_null())
606 .to_owned(),
607 )
608 .await
609 }
610 }
611
612 fn config() -> PreparedForm {
613 let config = FormConfig {
614 entity: "samples".to_string(),
615 title: "Sample".into(),
616 base_path: "/admin/samples".to_string(),
617 id_field: "id".to_string(),
618 fields: vec![
619 FormField::text("code", "Code").required().unique(),
620 FormField::text("name", "Name").required(),
621 ],
622 persist: None,
623 };
624 PreparedForm::prepare(
625 config,
626 &crate::field::builtin_registry(),
627 &PersisterRegistry::new(),
628 )
629 .unwrap()
630 }
631
632 fn state(db: Db) -> AdminState {
633 AdminState::new(
634 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
635 db,
636 )
637 }
638
639 async fn test_db() -> (Db, TestGuard) {
642 let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
643 connect_test(&[samples]).await
644 }
645
646 async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
649 let stmt = Query::select()
650 .expr_as(
651 Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
652 Alias::new("v"),
653 )
654 .from(Alias::new("samples"))
655 .and_where(Expr::col(Alias::new("code")).eq(code))
656 .to_owned();
657 let (sql, values) = to_sql(db.backend, stmt);
658 let row = bind_values(sqlx::query(&sql), values)
659 .fetch_optional(&db.pool)
660 .await
661 .unwrap()?;
662 row.get_text_opt("v").ok().flatten()
663 }
664
665 async fn body_of(resp: Response) -> String {
666 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
667 .await
668 .unwrap();
669 String::from_utf8(bytes.to_vec()).unwrap()
670 }
671
672 async fn count(db: &Db) -> i64 {
673 sqlx::query_scalar("select count(*) from samples")
674 .fetch_one(&db.pool)
675 .await
676 .unwrap()
677 }
678
679 fn data(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 #[tokio::test]
687 async fn create_then_fetch() {
688 let (db, _guard) = test_db().await;
689 let cfg = config();
690 let st = state(db.clone());
691
692 let resp = create(
693 &st,
694 &cfg,
695 data(&[("code", "editor"), ("name", "Content Editor")]),
696 crate::Shell::test(),
697 &crate::audit::test_actor(),
698 )
699 .await;
700 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
701
702 assert_eq!(
703 fetch_text(&db, "name", "editor").await.as_deref(),
704 Some("Content Editor")
705 );
706 }
707
708 #[tokio::test]
709 async fn update_changes_the_row() {
710 let (db, _guard) = test_db().await;
711 let cfg = config();
712 let st = state(db.clone());
713 create(
714 &st,
715 &cfg,
716 data(&[("code", "editor"), ("name", "Editor")]),
717 crate::Shell::test(),
718 &crate::audit::test_actor(),
719 )
720 .await;
721
722 let id = fetch_text(&db, "id", "editor")
723 .await
724 .expect("row should exist after create");
725
726 let resp = update(
728 &st,
729 &cfg,
730 id,
731 data(&[("code", "editor"), ("name", "Senior Editor")]),
732 crate::Shell::test(),
733 &crate::audit::test_actor(),
734 )
735 .await;
736 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
737
738 assert_eq!(
739 fetch_text(&db, "name", "editor").await.as_deref(),
740 Some("Senior Editor")
741 );
742 }
743
744 #[tokio::test]
745 async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
746 let (db, _guard) = test_db().await;
747 let cfg = config();
748 let st = state(db.clone());
749
750 let resp = create(
751 &st,
752 &cfg,
753 data(&[("code", ""), ("name", "No Code")]),
754 crate::Shell::test(),
755 &crate::audit::test_actor(),
756 )
757 .await;
758 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
760 assert_eq!(count(&db).await, 0);
761 assert!(body_of(resp).await.contains("Code is required."));
763 }
764
765 #[tokio::test]
766 async fn create_rejects_a_duplicate_unique_value() {
767 let (db, _guard) = test_db().await;
768 let cfg = config();
769 let st = state(db.clone());
770 create(
771 &st,
772 &cfg,
773 data(&[("code", "editor"), ("name", "Editor")]),
774 crate::Shell::test(),
775 &crate::audit::test_actor(),
776 )
777 .await;
778
779 let resp = create(
781 &st,
782 &cfg,
783 data(&[("code", "editor"), ("name", "Other")]),
784 crate::Shell::test(),
785 &crate::audit::test_actor(),
786 )
787 .await;
788 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
789 assert_eq!(count(&db).await, 1);
790 assert!(body_of(resp).await.contains("already taken"));
791 }
792
793 #[tokio::test]
794 async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
795 let (db, _guard) = test_db().await;
796 let st = state(db.clone());
797 let cfg = PreparedForm::prepare(
800 FormConfig {
801 entity: "samples".to_string(),
802 title: "Sample".into(),
803 base_path: "/admin/samples".to_string(),
804 id_field: "id".to_string(),
805 fields: vec![
806 FormField::text("code", "Code").required(),
807 FormField::of("name", "Email", "text")
808 .options(serde_json::json!({ "input": "email" }))
809 .required(),
810 ],
811 persist: None,
812 },
813 &st.field_types,
814 &PersisterRegistry::new(),
815 )
816 .unwrap();
817
818 let resp = create(
819 &st,
820 &cfg,
821 data(&[("code", "c1"), ("name", "nope")]),
822 crate::Shell::test(),
823 &crate::audit::test_actor(),
824 )
825 .await;
826 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
827 assert_eq!(count(&db).await, 0);
828
829 let resp = create(
830 &st,
831 &cfg,
832 data(&[("code", "c1"), ("name", "a@b.test")]),
833 crate::Shell::test(),
834 &crate::audit::test_actor(),
835 )
836 .await;
837 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
838 assert_eq!(count(&db).await, 1);
839 }
840
841 #[test]
842 fn prepare_rejects_an_unregistered_field_type() {
843 let config = FormConfig {
844 entity: "samples".to_string(),
845 title: "Sample".into(),
846 base_path: "/admin/samples".to_string(),
847 id_field: "id".to_string(),
848 fields: vec![FormField::of("place", "Place", "no.such.type")],
849 persist: None,
850 };
851 let Err(err) = PreparedForm::prepare(
852 config,
853 &crate::field::builtin_registry(),
854 &PersisterRegistry::new(),
855 ) else {
856 panic!("expected prepare to reject an unregistered type");
857 };
858 assert!(err.contains("no.such.type"), "{err}");
859 }
860
861 #[test]
862 fn prepare_rejects_a_malformed_option() {
863 let config = FormConfig {
866 entity: "samples".to_string(),
867 title: "Sample".into(),
868 base_path: "/admin/samples".to_string(),
869 id_field: "id".to_string(),
870 fields: vec![
871 FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
872 ],
873 persist: None,
874 };
875 let Err(err) = PreparedForm::prepare(
876 config,
877 &crate::field::builtin_registry(),
878 &PersisterRegistry::new(),
879 ) else {
880 panic!("expected prepare to reject a malformed option");
881 };
882 assert!(err.contains("`email`"), "{err}");
883 }
884
885 struct RollbackPersister;
888 #[laterite_core::strata::async_trait]
889 impl Persister for RollbackPersister {
890 async fn create(
891 &self,
892 db: &laterite_core::Db,
893 _data: &HashMap<String, String>,
894 ) -> Result<i64, SaveError> {
895 let mut tx = db
896 .pool
897 .begin()
898 .await
899 .map_err(|e| SaveError::Failed(e.to_string()))?;
900 let insert = Query::insert()
901 .into_table(Alias::new("samples"))
902 .columns([Alias::new("code"), Alias::new("name")])
903 .values_panic(["rolled".into(), "back".into()])
904 .to_owned();
905 let (sql, values) = to_sql(db.backend, insert);
906 bind_values(sqlx::query(&sql), values)
907 .execute(&mut *tx)
908 .await
909 .map_err(|e| SaveError::Failed(e.to_string()))?;
910 Err(SaveError::Failed("deliberate".to_string()))
912 }
913 async fn update(
914 &self,
915 _db: &laterite_core::Db,
916 _id: &str,
917 _data: &HashMap<String, String>,
918 ) -> Result<(), SaveError> {
919 Ok(())
920 }
921 }
922
923 struct RejectingPersister;
925 #[laterite_core::strata::async_trait]
926 impl Persister for RejectingPersister {
927 async fn create(
928 &self,
929 _db: &laterite_core::Db,
930 _data: &HashMap<String, String>,
931 ) -> Result<i64, SaveError> {
932 let mut bag = ErrorBag::default();
933 bag.add("code", t!("Code is not allowed here."));
934 Err(SaveError::Invalid(bag))
935 }
936 async fn update(
937 &self,
938 _db: &laterite_core::Db,
939 _id: &str,
940 _data: &HashMap<String, String>,
941 ) -> Result<(), SaveError> {
942 Ok(())
943 }
944 }
945
946 fn config_with(persister: &str) -> FormConfig {
947 FormConfig {
948 entity: "samples".to_string(),
949 title: "Sample".into(),
950 base_path: "/admin/samples".to_string(),
951 id_field: "id".to_string(),
952 fields: vec![
953 FormField::text("code", "Code").required(),
954 FormField::text("name", "Name").required(),
955 ],
956 persist: Some(persister.to_string()),
957 }
958 }
959
960 #[test]
961 fn prepare_rejects_an_unregistered_persister() {
962 let Err(err) = PreparedForm::prepare(
963 config_with("no.such.persister"),
964 &crate::field::builtin_registry(),
965 &PersisterRegistry::new(),
966 ) else {
967 panic!("expected prepare to reject an unregistered persister");
968 };
969 assert!(err.contains("no.such.persister"), "{err}");
970 }
971
972 #[tokio::test]
973 async fn a_persister_transaction_rolls_back_on_error() {
974 let (db, _guard) = test_db().await;
975 let st = state(db.clone());
976 let mut persisters = PersisterRegistry::new();
977 persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
978 let form =
979 PreparedForm::prepare(config_with("test.rollback"), &st.field_types, &persisters)
980 .unwrap();
981
982 let resp = create(
983 &st,
984 &form,
985 data(&[("code", "rolled"), ("name", "back")]),
986 crate::Shell::test(),
987 &crate::audit::test_actor(),
988 )
989 .await;
990 assert_eq!(resp.status(), StatusCode::OK);
993 assert_eq!(count(&db).await, 0);
994 }
995
996 #[tokio::test]
997 async fn a_persister_invalid_error_re_renders_422() {
998 let (db, _guard) = test_db().await;
999 let st = state(db.clone());
1000 let mut persisters = PersisterRegistry::new();
1001 persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1002 let form = PreparedForm::prepare(config_with("test.reject"), &st.field_types, &persisters)
1003 .unwrap();
1004
1005 let resp = create(
1006 &st,
1007 &form,
1008 data(&[("code", "editor"), ("name", "Editor")]),
1009 crate::Shell::test(),
1010 &crate::audit::test_actor(),
1011 )
1012 .await;
1013 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1014 assert!(body_of(resp).await.contains("Code is not allowed here."));
1015 }
1016}