Skip to main content

laterite_admin/
form.rs

1//! Descriptor-driven create and edit forms.
2//!
3//! A [`FormConfig`] describes a table and its editable fields. Generic handlers
4//! render an empty form (new), a populated form (edit), and persist through a
5//! [`crate::persist::Persister`]: the descriptor-driven default (a parameterized
6//! insert/update over the form's columns), or a named handler for a custom write.
7//! A submission is checked by the framework validation engine
8//! ([`laterite_core::validation`]); a failure re-renders the form with per-field
9//! messages instead of writing.
10//!
11//! Field types resolve through the registry ([`crate::field`]); `text`,
12//! `textarea`, `select`, and `reference` ship built-in. Fields needing a typed
13//! stored value (a switch over a bool column, password hashing) arrive with the
14//! typed-save contract.
15//!
16//! The primary key is a `bigint` auto-increment column the database assigns, so
17//! create inserts only the descriptor's fields and never sets the id. An entity
18//! with other required columns that lack defaults (for example audit timestamps)
19//! is beyond this slice; those are filled by a later timestamp-aware widget.
20
21use 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/// One editable field: the column, its label, its field-type key (resolved
39/// through the field-type registry), typed options for that type, the validation
40/// rules it carries, and whether it holds translatable content. A serde
41/// descriptor, so it is authorable as data (later YAML) as well as by builder.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FormField {
44    pub name: String,
45    /// The field's label, localized at render. Serde stays a plain string.
46    pub label: Text,
47    /// The field-type registry key (`text`, `textarea`, or a plugin's
48    /// `vendor.name`), resolved to behaviour at render time (see [`crate::field`]).
49    #[serde(rename = "type")]
50    pub field_type: String,
51    /// Per-type options, typed by the field type. Absent (null) for scalar types.
52    #[serde(default)]
53    pub options: serde_json::Value,
54    /// Validation rules run on submit (see [`laterite_core::validation`]).
55    #[serde(default)]
56    pub rules: Vec<Rule>,
57    /// Marks a field whose value is translatable content. A reserved seam: the
58    /// framework stores the value verbatim; a content-translation plugin reads
59    /// the flag to manage per-locale values.
60    #[serde(default)]
61    pub translatable: bool,
62}
63
64impl FormField {
65    /// A field of a registered type by key.
66    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    /// A reference field: a picker over a registered picker source (a `vendor.name`
86    /// key), which the field searches and resolves against. Builds the `source`
87    /// option so a descriptor needs no raw JSON.
88    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    /// Sets the field type's typed options (the type validates them at render).
93    pub fn options(mut self, options: serde_json::Value) -> Self {
94        self.options = options;
95        self
96    }
97
98    /// Requires a non-empty value in every mode.
99    pub fn required(mut self) -> Self {
100        self.rules.push(Rule::Required);
101        self
102    }
103
104    /// Requires a non-empty value in one mode only (for example a password set on
105    /// create but left unchanged on edit).
106    pub fn required_on(mut self, mode: Mode) -> Self {
107        self.rules.push(Rule::RequiredOn(mode));
108        self
109    }
110
111    /// Requires the value to be unique in this field's column (a DB probe that
112    /// ignores the edited row on update).
113    pub fn unique(mut self) -> Self {
114        self.rules.push(Rule::Unique);
115        self
116    }
117
118    /// Caps the value's length.
119    pub fn max_length(mut self, n: usize) -> Self {
120        self.rules.push(Rule::MaxLength(n));
121        self
122    }
123
124    /// Requires at least `n` characters when the value is non-empty.
125    pub fn min_length(mut self, n: usize) -> Self {
126        self.rules.push(Rule::MinLength(n));
127        self
128    }
129
130    /// Requires a syntactically valid email address.
131    pub fn email(mut self) -> Self {
132        self.rules.push(Rule::Email);
133        self
134    }
135
136    /// Marks this field as translatable content (see [`FormField::translatable`]).
137    pub fn translatable(mut self) -> Self {
138        self.translatable = true;
139        self
140    }
141}
142
143/// A form descriptor: which table, its editable fields, the id column, and the
144/// base path the form lives under (`{base_path}/new`, `{base_path}/{id}/edit`).
145#[derive(Debug, Clone, Serialize)]
146pub struct FormConfig {
147    pub entity: String,
148    /// The screen title, localized at render. Serde stays a plain string.
149    pub title: Text,
150    pub base_path: String,
151    pub id_field: String,
152    pub fields: Vec<FormField>,
153    /// The registered persister that writes this form (a dotted `vendor.name`),
154    /// or `None` for the built-in descriptor insert/update. See [`crate::persist`].
155    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
166/// A [`FormConfig`] with each field's options resolved once, at router build.
167/// Handlers carry this so option resolution and intrinsic-rule merging happen a
168/// single time (not per render), and a malformed option or unregistered type
169/// aborts boot rather than degrading silently to plain text.
170pub(crate) struct PreparedForm {
171    config: FormConfig,
172    /// Parallel to `config.fields`.
173    fields: Vec<PreparedField>,
174    /// The write handler: the named persister, or the built-in default.
175    persister: Arc<dyn Persister>,
176}
177
178/// One field's boot-resolved state: its typed options and its merged rules (the
179/// type's intrinsic rules ahead of the descriptor's own).
180struct PreparedField {
181    opts: ResolvedOptions,
182    rules: Vec<Rule>,
183}
184
185impl PreparedForm {
186    /// Resolves every field against the registry. Returns the offending field
187    /// and reason (for a boot abort) when a type is unregistered or rejects its
188    /// options.
189    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
224/// The merged validation rules for every field, in order.
225fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
226    form.config
227        .fields
228        .iter()
229        .zip(&form.fields)
230        // The rules carry the label's source string; the validation message re-wraps
231        // it as a nested Text, so it localizes at render like any other label.
232        .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
233        .collect()
234}
235
236/// Whether any rule marks the field required.
237fn has_required(rules: &[Rule]) -> bool {
238    rules
239        .iter()
240        .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
241}
242
243/// Renders an empty create form.
244pub(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
256/// Persists a new record, then redirects to the list. Re-renders with per-field
257/// errors when validation fails.
258pub(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        // A failed submission re-renders the form with per-field errors as 422,
286        // the cross-surface "validation failure" status.
287        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        // A persist-time domain check re-renders 422 with its per-field messages.
310        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
330/// Renders a form populated with an existing record.
331pub(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    // Scope the sea-query builder so it is dropped before the await below: its
341    // identifiers are reference-counted (not `Send`), and a live builder across
342    // the await would make this handler's future non-`Send`.
343    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
395/// Persists an edited record, then redirects to the list. Re-renders with
396/// per-field errors when validation fails.
397pub(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        // A failed submission re-renders the form with per-field errors as 422,
426        // the cross-surface "validation failure" status.
427        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            // Localize the label once: the field type sees it (aria/inline labels)
486            // and the framework chrome renders it.
487            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            // The type is registered (prepare validated it); the lookup here is
498            // only to render.
499            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                // Localize each per-field message through the request translator.
517                errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
518            }
519        })
520        .collect();
521    // Collect the widget assets the rendered field types declare, deduped and
522    // resolved to URLs for the head. Most declare none (their widgets ship in
523    // core laterite.js).
524    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    // Localize the title and banner before the shell is moved into the template.
538    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    /// DOM id for the control and its label's `for`.
554    id: String,
555    label: String,
556    /// The control HTML, rendered by the field-type registry (override-aware).
557    /// The framework owns the surrounding chrome (label, required marker, errors).
558    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    /// A minimal table for exercising the generic insert/update path in isolation,
585    /// defined as a portable migration so the test runs on any backend.
586    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    /// A fresh test database holding a minimal `samples` table, on whichever
640    /// backend the run targets. Hold the returned guard for the test's lifetime.
641    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    /// Reads a single text column from the one row matching `code`, so a test can
647    /// assert what was persisted without depending on the read path under test.
648    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        // The code is unchanged, so its unique rule must ignore the edited row.
727        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        // Re-renders the form (200), does not redirect.
759        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
760        assert_eq!(count(&db).await, 0);
761        // The per-field message is rendered.
762        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        // A second row with the same code is refused by the unique rule.
780        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        // The `name` column is a text field with the `email` input, which
798        // contributes Rule::Email without the descriptor listing it.
799        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        // The text field's `input` option is a string; a wrong JSON type for it
864        // aborts prepare naming the field.
865        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    /// A persister that inserts a row then fails, without committing, so its own
886    /// transaction must roll the insert back.
887    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            // Return before commit: the transaction drops and rolls back.
911            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    /// A persister that always rejects create with a per-field validation error.
924    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        // The persister failed, so the form re-renders (200) and its in-tx insert
991        // rolled back: no row landed.
992        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}