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::{
30    t, Actor, AnyRowExt, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
31};
32use sea_query::{Alias, Expr, Query};
33use serde::{Deserialize, Serialize};
34
35use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
36use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
37use crate::sql::valid_ident;
38use crate::{not_found, render, render_error, AdminState};
39
40/// One editable field: the column, its label, its field-type key (resolved
41/// through the field-type registry), typed options for that type, the validation
42/// rules it carries, and whether it holds translatable content. A serde
43/// descriptor, so it is authorable as data (later YAML) as well as by builder.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FormField {
46    pub name: String,
47    /// The field's label, localized at render. Serde stays a plain string.
48    pub label: Text,
49    /// The field-type registry key (`text`, `textarea`, or a plugin's
50    /// `vendor.name`), resolved to behaviour at render time (see [`crate::field`]).
51    #[serde(rename = "type")]
52    pub field_type: String,
53    /// Per-type options, typed by the field type. Absent (null) for scalar types.
54    #[serde(default)]
55    pub options: serde_json::Value,
56    /// Validation rules run on submit (see [`laterite_core::validation`]).
57    #[serde(default)]
58    pub rules: Vec<Rule>,
59    /// Marks a field whose value is translatable content. A reserved seam: the
60    /// framework stores the value verbatim; a content-translation plugin reads
61    /// the flag to manage per-locale values.
62    #[serde(default)]
63    pub translatable: bool,
64}
65
66impl FormField {
67    /// A field of a registered type by key.
68    pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
69        Self {
70            name: name.to_string(),
71            label: label.into(),
72            field_type: field_type.to_string(),
73            options: serde_json::Value::Null,
74            rules: Vec::new(),
75            translatable: false,
76        }
77    }
78
79    pub fn text(name: &str, label: impl Into<Text>) -> Self {
80        Self::of(name, label, "text")
81    }
82
83    pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
84        Self::of(name, label, "textarea")
85    }
86
87    /// A reference field: a picker over a registered picker source (a `vendor.name`
88    /// key), which the field searches and resolves against. Builds the `source`
89    /// option so a descriptor needs no raw JSON.
90    pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
91        Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
92    }
93
94    /// Sets the field type's typed options (the type validates them at render).
95    pub fn options(mut self, options: serde_json::Value) -> Self {
96        self.options = options;
97        self
98    }
99
100    /// Requires a non-empty value in every mode.
101    pub fn required(mut self) -> Self {
102        self.rules.push(Rule::Required);
103        self
104    }
105
106    /// Requires a non-empty value in one mode only (for example a password set on
107    /// create but left unchanged on edit).
108    pub fn required_on(mut self, mode: Mode) -> Self {
109        self.rules.push(Rule::RequiredOn(mode));
110        self
111    }
112
113    /// Requires the value to be unique in this field's column (a DB probe that
114    /// ignores the edited row on update).
115    pub fn unique(mut self) -> Self {
116        self.rules.push(Rule::Unique);
117        self
118    }
119
120    /// Caps the value's length.
121    pub fn max_length(mut self, n: usize) -> Self {
122        self.rules.push(Rule::MaxLength(n));
123        self
124    }
125
126    /// Requires at least `n` characters when the value is non-empty.
127    pub fn min_length(mut self, n: usize) -> Self {
128        self.rules.push(Rule::MinLength(n));
129        self
130    }
131
132    /// Requires a syntactically valid email address.
133    pub fn email(mut self) -> Self {
134        self.rules.push(Rule::Email);
135        self
136    }
137
138    /// Marks this field as translatable content (see [`FormField::translatable`]).
139    pub fn translatable(mut self) -> Self {
140        self.translatable = true;
141        self
142    }
143}
144
145/// A form descriptor: which table, its editable fields, the id column, and the
146/// base path the form lives under (`{base_path}/new`, `{base_path}/{id}/edit`).
147#[derive(Debug, Clone, Serialize)]
148pub struct FormConfig {
149    pub entity: String,
150    /// The screen title, localized at render. Serde stays a plain string.
151    pub title: Text,
152    pub base_path: String,
153    pub id_field: String,
154    pub fields: Vec<FormField>,
155    /// The registered persister that writes this form (a dotted `vendor.name`),
156    /// or `None` for the built-in descriptor insert/update. See [`crate::persist`].
157    pub persist: Option<String>,
158    /// Stamp `created_at` and `updated_at` on write. Opt in per form, because
159    /// the columns have to exist on this entity's table.
160    #[serde(default)]
161    pub timestamps: bool,
162}
163
164impl FormConfig {
165    fn idents_valid(&self) -> bool {
166        valid_ident(&self.entity)
167            && valid_ident(&self.id_field)
168            && self.fields.iter().all(|f| valid_ident(&f.name))
169    }
170}
171
172/// A [`FormConfig`] with each field's options resolved once, at router build.
173/// Handlers carry this so option resolution and intrinsic-rule merging happen a
174/// single time (not per render), and a malformed option or unregistered type
175/// aborts boot rather than degrading silently to plain text.
176pub(crate) struct PreparedForm {
177    config: FormConfig,
178    /// Parallel to `config.fields`.
179    fields: Vec<PreparedField>,
180    /// The write handler: the named persister, or the built-in default.
181    persister: Arc<dyn Persister>,
182    /// Listeners for this form's entity, in registration order.
183    listeners: Vec<Arc<dyn ModelListener>>,
184}
185
186/// One field's boot-resolved state: its typed options and its merged rules (the
187/// type's intrinsic rules ahead of the descriptor's own).
188struct PreparedField {
189    /// Resolved once at boot, so the save and load hooks need no lookup.
190    field_type: Arc<dyn crate::field::FieldType>,
191    opts: ResolvedOptions,
192    rules: Vec<Rule>,
193}
194
195impl PreparedForm {
196    /// Resolves every field against the registry. Returns the offending field
197    /// and reason (for a boot abort) when a type is unregistered or rejects its
198    /// options.
199    pub(crate) fn prepare(
200        config: FormConfig,
201        field_types: &crate::field::FieldRegistry,
202        persisters: &PersisterRegistry,
203        listeners: &[ModelListenerReg],
204    ) -> Result<Self, String> {
205        let mut fields = Vec::with_capacity(config.fields.len());
206        for f in &config.fields {
207            let ft = field_types.get(&f.field_type).ok_or_else(|| {
208                format!(
209                    "field `{}` uses unregistered type `{}`",
210                    f.name, f.field_type
211                )
212            })?;
213            let opts = ft
214                .resolve_options(&f.options)
215                .map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
216            let mut rules = ft.intrinsic_rules(&opts);
217            rules.extend(f.rules.clone());
218            fields.push(PreparedField {
219                field_type: ft.clone(),
220                opts,
221                rules,
222            });
223        }
224        let persister: Arc<dyn Persister> = match &config.persist {
225            Some(key) => persisters
226                .get(key)
227                .cloned()
228                .ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
229            None => Arc::new(DefaultPersister::from_config(&config)),
230        };
231        // The framework's own listeners run first, so a contributed one can see
232        // or override what they set.
233        let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
234        if config.timestamps {
235            resolved.push(Arc::new(laterite_core::Timestamps));
236        }
237        resolved.extend(
238            listeners
239                .iter()
240                .filter(|reg| reg.matches(&config.entity))
241                .map(|reg| reg.listener.clone()),
242        );
243        let listeners = resolved;
244        Ok(Self {
245            config,
246            fields,
247            persister,
248            listeners,
249        })
250    }
251}
252
253/// The submission as a record, carrying only the fields the descriptor declares.
254/// A request cannot introduce an attribute this way, so the persister may widen
255/// its write to whatever the record holds; only registered listeners add keys.
256///
257/// Each value passes through its field type, which decides what gets stored and
258/// may leave the attribute out entirely (a blank password on an edit).
259fn declared_record(
260    form: &PreparedForm,
261    data: &HashMap<String, String>,
262    mode: Mode,
263) -> Result<Record, String> {
264    let mut rec = Record::new(&form.config.entity);
265    for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
266        let raw = data.get(&field.name).map(String::as_str);
267        if let Some(value) = prepared.field_type.to_attr(raw, &prepared.opts, mode)? {
268            rec.set(field.name.clone(), value);
269        }
270    }
271    Ok(rec)
272}
273
274/// The merged validation rules for every field, in order.
275fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
276    form.config
277        .fields
278        .iter()
279        .zip(&form.fields)
280        // The rules carry the label's source string; the validation message re-wraps
281        // it as a nested Text, so it localizes at render like any other label.
282        .map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
283        .collect()
284}
285
286/// Whether any rule marks the field required.
287fn has_required(rules: &[Rule]) -> bool {
288    rules
289        .iter()
290        .any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
291}
292
293/// Renders an empty create form.
294pub(crate) fn new_form(state: &AdminState, form: &PreparedForm, shell: crate::Shell) -> Response {
295    render(build(
296        state,
297        form,
298        &format!("{}/new", form.config.base_path),
299        None,
300        &HashMap::new(),
301        &ErrorBag::default(),
302        &shell,
303    ))
304}
305
306/// Persists a new record, then redirects to the list. Re-renders with per-field
307/// errors when validation fails.
308pub(crate) async fn create(
309    state: &AdminState,
310    form: &PreparedForm,
311    data: HashMap<String, String>,
312    shell: crate::Shell,
313    user: &laterite_auth::AuthenticatedUser,
314) -> Response {
315    if !form.config.idents_valid() {
316        return render_error();
317    }
318    let action = format!("{}/new", form.config.base_path);
319
320    let bag = match validate(
321        &state.db,
322        &form.config.entity,
323        &form.config.id_field,
324        &merged_field_rules(form),
325        &data,
326        Mode::Create,
327        None,
328    )
329    .await
330    {
331        Ok(bag) => bag,
332        Err(_) => return render_error(),
333    };
334    if !bag.is_empty() {
335        // A failed submission re-renders the form with per-field errors as 422,
336        // the cross-surface "validation failure" status.
337        return (
338            StatusCode::UNPROCESSABLE_ENTITY,
339            render(build(state, form, &action, None, &data, &bag, &shell)),
340        )
341            .into_response();
342    }
343
344    let mut rec = match declared_record(form, &data, Mode::Create) {
345        Ok(rec) => rec,
346        Err(e) => {
347            tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
348            return render_error();
349        }
350    };
351    let actor = Actor::from(user);
352    match persist::save(
353        persist::SaveRequest {
354            db: &state.db,
355            listeners: &form.listeners,
356            persister: form.persister.as_ref(),
357            actor: &actor,
358            op: Op::Create,
359            id: None,
360        },
361        &mut rec,
362    )
363    .await
364    {
365        // The audit entry is written by the framework's audit listener, which
366        // runs on every entity after the commit.
367        Ok(()) => Redirect::to(&form.config.base_path).into_response(),
368        // A persist-time domain check re-renders 422 with its per-field messages.
369        Err(SaveError::Invalid(bag)) => (
370            StatusCode::UNPROCESSABLE_ENTITY,
371            render(build(state, form, &action, None, &data, &bag, &shell)),
372        )
373            .into_response(),
374        Err(SaveError::Failed(msg)) => {
375            tracing::error!(error = %msg, "admin create failed");
376            render(build(
377                state,
378                form,
379                &action,
380                Some(t!("Could not save. Check the values and try again.")),
381                &data,
382                &ErrorBag::default(),
383                &shell,
384            ))
385        }
386    }
387}
388
389/// Renders a form populated with an existing record.
390pub(crate) async fn edit_form(
391    state: &AdminState,
392    form: &PreparedForm,
393    id: String,
394    shell: crate::Shell,
395) -> Response {
396    if !form.config.idents_valid() {
397        return render_error();
398    }
399    // Scope the sea-query builder so it is dropped before the await below: its
400    // identifiers are reference-counted (not `Send`), and a live builder across
401    // the await would make this handler's future non-`Send`.
402    let (sql, values) = {
403        let cast = text_cast(state.db.backend);
404        let mut select = Query::select();
405        for field in &form.config.fields {
406            select.expr_as(
407                Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
408                Alias::new(&field.name),
409            );
410        }
411        select.from(Alias::new(&form.config.entity)).and_where(
412            Expr::col(Alias::new(&form.config.id_field))
413                .cast_as(Alias::new(cast))
414                .eq(id.clone()),
415        );
416        to_sql(state.db.backend, select)
417    };
418    let row = match bind_values(sqlx::query(&sql), values)
419        .fetch_optional(&state.db.pool)
420        .await
421    {
422        Ok(row) => row,
423        Err(_) => return render_error(),
424    };
425    let Some(row) = row else {
426        return not_found();
427    };
428
429    // Each stored value goes back through its field type: what a column holds and
430    // what its control shows are not always the same string.
431    let values = form
432        .config
433        .fields
434        .iter()
435        .zip(&form.fields)
436        .map(|(f, prepared)| {
437            let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
438            let value = prepared
439                .field_type
440                .to_control(stored.as_deref(), &prepared.opts);
441            (f.name.clone(), value)
442        })
443        .collect();
444
445    render(build(
446        state,
447        form,
448        &format!("{}/{}/edit", form.config.base_path, id),
449        None,
450        &values,
451        &ErrorBag::default(),
452        &shell,
453    ))
454}
455
456/// Persists an edited record, then redirects to the list. Re-renders with
457/// per-field errors when validation fails.
458pub(crate) async fn update(
459    state: &AdminState,
460    form: &PreparedForm,
461    id: String,
462    data: HashMap<String, String>,
463    shell: crate::Shell,
464    user: &laterite_auth::AuthenticatedUser,
465) -> Response {
466    if !form.config.idents_valid() {
467        return render_error();
468    }
469    let action = format!("{}/{}/edit", form.config.base_path, id);
470
471    let bag = match validate(
472        &state.db,
473        &form.config.entity,
474        &form.config.id_field,
475        &merged_field_rules(form),
476        &data,
477        Mode::Update,
478        Some(&id),
479    )
480    .await
481    {
482        Ok(bag) => bag,
483        Err(_) => return render_error(),
484    };
485    if !bag.is_empty() {
486        // A failed submission re-renders the form with per-field errors as 422,
487        // the cross-surface "validation failure" status.
488        return (
489            StatusCode::UNPROCESSABLE_ENTITY,
490            render(build(state, form, &action, None, &data, &bag, &shell)),
491        )
492            .into_response();
493    }
494
495    let mut rec = match declared_record(form, &data, Mode::Update) {
496        Ok(rec) => rec,
497        Err(e) => {
498            tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
499            return render_error();
500        }
501    };
502    if let Ok(n) = id.parse::<i64>() {
503        rec.set_id(n);
504    }
505    let actor = Actor::from(user);
506    match persist::save(
507        persist::SaveRequest {
508            db: &state.db,
509            listeners: &form.listeners,
510            persister: form.persister.as_ref(),
511            actor: &actor,
512            op: Op::Update,
513            id: Some(&id),
514        },
515        &mut rec,
516    )
517    .await
518    {
519        Ok(()) => Redirect::to(&form.config.base_path).into_response(),
520        Err(SaveError::Invalid(bag)) => (
521            StatusCode::UNPROCESSABLE_ENTITY,
522            render(build(state, form, &action, None, &data, &bag, &shell)),
523        )
524            .into_response(),
525        Err(SaveError::Failed(msg)) => {
526            tracing::error!(error = %msg, "admin update failed");
527            render(build(
528                state,
529                form,
530                &action,
531                Some(t!("Could not save. Check the values and try again.")),
532                &data,
533                &ErrorBag::default(),
534                &shell,
535            ))
536        }
537    }
538}
539
540fn build(
541    state: &AdminState,
542    form: &PreparedForm,
543    action: &str,
544    error: Option<Text>,
545    values: &HashMap<String, String>,
546    bag: &ErrorBag,
547    shell: &crate::Shell,
548) -> FormTemplate {
549    let fields = form
550        .config
551        .fields
552        .iter()
553        .zip(&form.fields)
554        .map(|(f, pf)| {
555            let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
556            let required = has_required(&pf.rules);
557            // Localize the label once: the field type sees it (aria/inline labels)
558            // and the framework chrome renders it.
559            let label = shell.tt(&f.label);
560            let cx = FieldCx {
561                name: &f.name,
562                id: &f.name,
563                label: &label,
564                value: &value,
565                required,
566                opts: &pf.opts,
567                base: &shell.base,
568            };
569            // The type is registered (prepare validated it); the lookup here is
570            // only to render.
571            let control = match state.field_types.get(&f.field_type) {
572                Some(ft) => {
573                    let scope = OverrideScope {
574                        surface: Surface::Field,
575                        view_key: &f.field_type,
576                        resource: Some(&form.config.base_path),
577                        field: Some(&f.name),
578                    };
579                    render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string()
580                }
581                None => String::new(),
582            };
583            FieldView {
584                id: f.name.clone(),
585                label,
586                control,
587                required,
588                // Localize each per-field message through the request translator.
589                errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
590            }
591        })
592        .collect();
593    // Collect the widget assets the rendered field types declare, deduped and
594    // resolved to URLs for the head. Most declare none (their widgets ship in
595    // core laterite.js).
596    let keys: Vec<&str> = form
597        .config
598        .fields
599        .iter()
600        .zip(&form.fields)
601        .filter_map(|(f, pf)| {
602            state
603                .field_types
604                .get(&f.field_type)
605                .map(|ft| ft.assets(&pf.opts))
606        })
607        .flatten()
608        .collect();
609    // Localize the title and banner before the shell is moved into the template.
610    let shell_title = shell.tt(&form.config.title);
611    let error = error.map(|m| shell.tt(&m));
612    let mut shell = shell.clone();
613    shell.assets = crate::page_assets(&keys, &shell.base, &state.assets);
614    FormTemplate {
615        shell,
616        title: shell_title,
617        action: action.to_string(),
618        cancel_path: form.config.base_path.clone(),
619        error,
620        fields,
621    }
622}
623
624struct FieldView {
625    /// DOM id for the control and its label's `for`.
626    id: String,
627    label: String,
628    /// The control HTML, rendered by the field-type registry (override-aware).
629    /// The framework owns the surrounding chrome (label, required marker, errors).
630    control: String,
631    required: bool,
632    errors: Vec<String>,
633}
634
635#[derive(Template)]
636#[template(path = "form.html")]
637struct FormTemplate {
638    shell: crate::Shell,
639    title: String,
640    action: String,
641    cancel_path: String,
642    error: Option<String>,
643    fields: Vec<FieldView>,
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use axum::http::StatusCode;
650    use laterite_core::strata::{
651        async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
652    };
653    use laterite_core::testing::{connect_test, TestGuard};
654    use laterite_core::Db;
655    use laterite_core::SaveCx;
656
657    /// A minimal table for exercising the generic insert/update path in isolation,
658    /// defined as a portable migration so the test runs on any backend.
659    struct CreateSamples;
660    #[async_trait(?Send)]
661    impl Migration for CreateSamples {
662        fn name(&self) -> &str {
663            "0001_create_samples"
664        }
665        async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
666            s.exec(
667                Table::create()
668                    .table(Alias::new("samples"))
669                    .if_not_exists()
670                    .col(
671                        ColumnDef::new(Alias::new("id"))
672                            .big_integer()
673                            .not_null()
674                            .auto_increment()
675                            .primary_key(),
676                    )
677                    .col(ColumnDef::new(Alias::new("code")).text().not_null())
678                    .col(ColumnDef::new(Alias::new("name")).text().not_null())
679                    // Nullable, so a form that does not opt into timestamps
680                    // still inserts.
681                    .col(ColumnDef::new(Alias::new("created_at")).text())
682                    .col(ColumnDef::new(Alias::new("updated_at")).text())
683                    .to_owned(),
684            )
685            .await
686        }
687    }
688
689    fn config() -> PreparedForm {
690        let config = FormConfig {
691            entity: "samples".to_string(),
692            title: "Sample".into(),
693            base_path: "/admin/samples".to_string(),
694            id_field: "id".to_string(),
695            fields: vec![
696                FormField::text("code", "Code").required().unique(),
697                FormField::text("name", "Name").required(),
698            ],
699            persist: None,
700            timestamps: false,
701        };
702        PreparedForm::prepare(
703            config,
704            &crate::field::builtin_registry(),
705            &PersisterRegistry::new(),
706            &[],
707        )
708        .unwrap()
709    }
710
711    fn state(db: Db) -> AdminState {
712        AdminState::new(
713            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
714            db,
715        )
716    }
717
718    /// A fresh test database holding a minimal `samples` table, on whichever
719    /// backend the run targets. Hold the returned guard for the test's lifetime.
720    pub(super) async fn test_db() -> (Db, TestGuard) {
721        let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
722        connect_test(&[samples]).await
723    }
724
725    /// Reads a single text column from the one row matching `code`, so a test can
726    /// assert what was persisted without depending on the read path under test.
727    pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
728        let stmt = Query::select()
729            .expr_as(
730                Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
731                Alias::new("v"),
732            )
733            .from(Alias::new("samples"))
734            .and_where(Expr::col(Alias::new("code")).eq(code))
735            .to_owned();
736        let (sql, values) = to_sql(db.backend, stmt);
737        let row = bind_values(sqlx::query(&sql), values)
738            .fetch_optional(&db.pool)
739            .await
740            .unwrap()?;
741        row.get_text_opt("v").ok().flatten()
742    }
743
744    async fn body_of(resp: Response) -> String {
745        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
746            .await
747            .unwrap();
748        String::from_utf8(bytes.to_vec()).unwrap()
749    }
750
751    async fn count(db: &Db) -> i64 {
752        sqlx::query_scalar("select count(*) from samples")
753            .fetch_one(&db.pool)
754            .await
755            .unwrap()
756    }
757
758    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
759        pairs
760            .iter()
761            .map(|(k, v)| (k.to_string(), v.to_string()))
762            .collect()
763    }
764
765    #[tokio::test]
766    async fn create_then_fetch() {
767        let (db, _guard) = test_db().await;
768        let cfg = config();
769        let st = state(db.clone());
770
771        let resp = create(
772            &st,
773            &cfg,
774            data(&[("code", "editor"), ("name", "Content Editor")]),
775            crate::Shell::test(),
776            &crate::audit::test_actor(),
777        )
778        .await;
779        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
780
781        assert_eq!(
782            fetch_text(&db, "name", "editor").await.as_deref(),
783            Some("Content Editor")
784        );
785    }
786
787    #[tokio::test]
788    async fn update_changes_the_row() {
789        let (db, _guard) = test_db().await;
790        let cfg = config();
791        let st = state(db.clone());
792        create(
793            &st,
794            &cfg,
795            data(&[("code", "editor"), ("name", "Editor")]),
796            crate::Shell::test(),
797            &crate::audit::test_actor(),
798        )
799        .await;
800
801        let id = fetch_text(&db, "id", "editor")
802            .await
803            .expect("row should exist after create");
804
805        // The code is unchanged, so its unique rule must ignore the edited row.
806        let resp = update(
807            &st,
808            &cfg,
809            id,
810            data(&[("code", "editor"), ("name", "Senior Editor")]),
811            crate::Shell::test(),
812            &crate::audit::test_actor(),
813        )
814        .await;
815        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
816
817        assert_eq!(
818            fetch_text(&db, "name", "editor").await.as_deref(),
819            Some("Senior Editor")
820        );
821    }
822
823    #[tokio::test]
824    async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
825        let (db, _guard) = test_db().await;
826        let cfg = config();
827        let st = state(db.clone());
828
829        let resp = create(
830            &st,
831            &cfg,
832            data(&[("code", ""), ("name", "No Code")]),
833            crate::Shell::test(),
834            &crate::audit::test_actor(),
835        )
836        .await;
837        // Re-renders the form (200), does not redirect.
838        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
839        assert_eq!(count(&db).await, 0);
840        // The per-field message is rendered.
841        assert!(body_of(resp).await.contains("Code is required."));
842    }
843
844    #[tokio::test]
845    async fn create_rejects_a_duplicate_unique_value() {
846        let (db, _guard) = test_db().await;
847        let cfg = config();
848        let st = state(db.clone());
849        create(
850            &st,
851            &cfg,
852            data(&[("code", "editor"), ("name", "Editor")]),
853            crate::Shell::test(),
854            &crate::audit::test_actor(),
855        )
856        .await;
857
858        // A second row with the same code is refused by the unique rule.
859        let resp = create(
860            &st,
861            &cfg,
862            data(&[("code", "editor"), ("name", "Other")]),
863            crate::Shell::test(),
864            &crate::audit::test_actor(),
865        )
866        .await;
867        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
868        assert_eq!(count(&db).await, 1);
869        assert!(body_of(resp).await.contains("already taken"));
870    }
871
872    #[tokio::test]
873    async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
874        let (db, _guard) = test_db().await;
875        let st = state(db.clone());
876        // The `name` column is a text field with the `email` input, which
877        // contributes Rule::Email without the descriptor listing it.
878        let cfg = PreparedForm::prepare(
879            FormConfig {
880                entity: "samples".to_string(),
881                title: "Sample".into(),
882                base_path: "/admin/samples".to_string(),
883                id_field: "id".to_string(),
884                fields: vec![
885                    FormField::text("code", "Code").required(),
886                    FormField::of("name", "Email", "text")
887                        .options(serde_json::json!({ "input": "email" }))
888                        .required(),
889                ],
890                persist: None,
891                timestamps: false,
892            },
893            &st.field_types,
894            &PersisterRegistry::new(),
895            &[],
896        )
897        .unwrap();
898
899        let resp = create(
900            &st,
901            &cfg,
902            data(&[("code", "c1"), ("name", "nope")]),
903            crate::Shell::test(),
904            &crate::audit::test_actor(),
905        )
906        .await;
907        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
908        assert_eq!(count(&db).await, 0);
909
910        let resp = create(
911            &st,
912            &cfg,
913            data(&[("code", "c1"), ("name", "a@b.test")]),
914            crate::Shell::test(),
915            &crate::audit::test_actor(),
916        )
917        .await;
918        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
919        assert_eq!(count(&db).await, 1);
920    }
921
922    #[test]
923    fn prepare_rejects_an_unregistered_field_type() {
924        let config = FormConfig {
925            entity: "samples".to_string(),
926            title: "Sample".into(),
927            base_path: "/admin/samples".to_string(),
928            id_field: "id".to_string(),
929            fields: vec![FormField::of("place", "Place", "no.such.type")],
930            persist: None,
931            timestamps: false,
932        };
933        let Err(err) = PreparedForm::prepare(
934            config,
935            &crate::field::builtin_registry(),
936            &PersisterRegistry::new(),
937            &[],
938        ) else {
939            panic!("expected prepare to reject an unregistered type");
940        };
941        assert!(err.contains("no.such.type"), "{err}");
942    }
943
944    #[test]
945    fn prepare_rejects_a_malformed_option() {
946        // The text field's `input` option is a string; a wrong JSON type for it
947        // aborts prepare naming the field.
948        let config = FormConfig {
949            entity: "samples".to_string(),
950            title: "Sample".into(),
951            base_path: "/admin/samples".to_string(),
952            id_field: "id".to_string(),
953            fields: vec![
954                FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
955            ],
956            persist: None,
957            timestamps: false,
958        };
959        let Err(err) = PreparedForm::prepare(
960            config,
961            &crate::field::builtin_registry(),
962            &PersisterRegistry::new(),
963            &[],
964        ) else {
965            panic!("expected prepare to reject a malformed option");
966        };
967        assert!(err.contains("`email`"), "{err}");
968    }
969
970    /// A persister that inserts a row then fails. The pipeline owns the
971    /// transaction, so the insert must roll back with it.
972    struct RollbackPersister;
973    #[laterite_core::strata::async_trait]
974    impl Persister for RollbackPersister {
975        async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
976            let backend = cx.backend();
977            let insert = Query::insert()
978                .into_table(Alias::new("samples"))
979                .columns([Alias::new("code"), Alias::new("name")])
980                .values_panic(["rolled".into(), "back".into()])
981                .to_owned();
982            let (sql, values) = to_sql(backend, insert);
983            bind_values(sqlx::query(&sql), values)
984                .execute(cx.conn())
985                .await
986                .map_err(|e| SaveError::Failed(e.to_string()))?;
987            Err(SaveError::Failed("deliberate".to_string()))
988        }
989        async fn update(
990            &self,
991            _cx: &mut SaveCx<'_>,
992            _id: &str,
993            _rec: &Record,
994        ) -> Result<(), SaveError> {
995            Ok(())
996        }
997    }
998
999    /// A persister that always rejects create with a per-field validation error.
1000    struct RejectingPersister;
1001    #[laterite_core::strata::async_trait]
1002    impl Persister for RejectingPersister {
1003        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
1004            let mut bag = ErrorBag::default();
1005            bag.add("code", t!("Code is not allowed here."));
1006            Err(SaveError::Invalid(bag))
1007        }
1008        async fn update(
1009            &self,
1010            _cx: &mut SaveCx<'_>,
1011            _id: &str,
1012            _rec: &Record,
1013        ) -> Result<(), SaveError> {
1014            Ok(())
1015        }
1016    }
1017
1018    fn config_with(persister: &str) -> FormConfig {
1019        FormConfig {
1020            entity: "samples".to_string(),
1021            title: "Sample".into(),
1022            base_path: "/admin/samples".to_string(),
1023            id_field: "id".to_string(),
1024            fields: vec![
1025                FormField::text("code", "Code").required(),
1026                FormField::text("name", "Name").required(),
1027            ],
1028            persist: Some(persister.to_string()),
1029            timestamps: false,
1030        }
1031    }
1032
1033    #[test]
1034    fn prepare_rejects_an_unregistered_persister() {
1035        let Err(err) = PreparedForm::prepare(
1036            config_with("no.such.persister"),
1037            &crate::field::builtin_registry(),
1038            &PersisterRegistry::new(),
1039            &[],
1040        ) else {
1041            panic!("expected prepare to reject an unregistered persister");
1042        };
1043        assert!(err.contains("no.such.persister"), "{err}");
1044    }
1045
1046    #[tokio::test]
1047    async fn a_persister_transaction_rolls_back_on_error() {
1048        let (db, _guard) = test_db().await;
1049        let st = state(db.clone());
1050        let mut persisters = PersisterRegistry::new();
1051        persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
1052        let form = PreparedForm::prepare(
1053            config_with("test.rollback"),
1054            &st.field_types,
1055            &persisters,
1056            &[],
1057        )
1058        .unwrap();
1059
1060        let resp = create(
1061            &st,
1062            &form,
1063            data(&[("code", "rolled"), ("name", "back")]),
1064            crate::Shell::test(),
1065            &crate::audit::test_actor(),
1066        )
1067        .await;
1068        // The persister failed, so the form re-renders (200) and its in-tx insert
1069        // rolled back: no row landed.
1070        assert_eq!(resp.status(), StatusCode::OK);
1071        assert_eq!(count(&db).await, 0);
1072    }
1073
1074    #[tokio::test]
1075    async fn a_persister_invalid_error_re_renders_422() {
1076        let (db, _guard) = test_db().await;
1077        let st = state(db.clone());
1078        let mut persisters = PersisterRegistry::new();
1079        persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
1080        let form = PreparedForm::prepare(
1081            config_with("test.reject"),
1082            &st.field_types,
1083            &persisters,
1084            &[],
1085        )
1086        .unwrap();
1087
1088        let resp = create(
1089            &st,
1090            &form,
1091            data(&[("code", "editor"), ("name", "Editor")]),
1092            crate::Shell::test(),
1093            &crate::audit::test_actor(),
1094        )
1095        .await;
1096        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
1097        assert!(body_of(resp).await.contains("Code is not allowed here."));
1098    }
1099}
1100
1101#[cfg(test)]
1102mod timestamp_tests {
1103    use super::tests::*;
1104    use super::*;
1105    use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
1106    use laterite_core::Actor;
1107
1108    fn timestamped(on: bool) -> FormConfig {
1109        FormConfig {
1110            entity: "samples".to_string(),
1111            title: "Sample".into(),
1112            base_path: "/admin/samples".to_string(),
1113            id_field: "id".to_string(),
1114            fields: vec![
1115                FormField::text("code", "Code").required(),
1116                FormField::text("name", "Name").required(),
1117            ],
1118            persist: None,
1119            timestamps: on,
1120        }
1121    }
1122
1123    async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
1124        let form = PreparedForm::prepare(
1125            timestamped(on),
1126            &crate::field::builtin_registry(),
1127            &PersisterRegistry::new(),
1128            &[],
1129        )
1130        .unwrap();
1131        let mut data = HashMap::new();
1132        data.insert("code".to_string(), "c1".to_string());
1133        data.insert("name".to_string(), "Chair".to_string());
1134        let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
1135        let actor = Actor::system("test");
1136        persist::save(
1137            persist::SaveRequest {
1138                db,
1139                listeners: &form.listeners,
1140                persister: form.persister.as_ref(),
1141                actor: &actor,
1142                op: Op::Create,
1143                id: None,
1144            },
1145            &mut rec,
1146        )
1147        .await
1148        .expect("the insert should succeed");
1149        rec
1150    }
1151
1152    #[tokio::test]
1153    async fn opting_in_stamps_the_row() {
1154        let (db, _guard) = test_db().await;
1155        let rec = create_through_pipeline(&db, true).await;
1156        assert!(rec.datetime(CREATED_AT).is_some());
1157        assert!(rec.datetime(UPDATED_AT).is_some());
1158        // The stamp reached the table, not just the record.
1159        assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
1160    }
1161
1162    #[tokio::test]
1163    async fn leaving_it_off_writes_no_timestamps() {
1164        let (db, _guard) = test_db().await;
1165        let rec = create_through_pipeline(&db, false).await;
1166        assert!(rec.get(CREATED_AT).is_none());
1167        assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
1168    }
1169}