use std::collections::{HashMap, HashSet};
use http::StatusCode;
use tablo_core::{
Ability, DeclarationErrorKind, Field, FieldErrorKind, FieldErrors, NoForm, Panel, RecordForm,
Repeater, Resource, ResourceDef, Schema, Table, Tenancy, Tenant, TextColumn, lens,
write_create,
};
use toasty::Db;
use topcoat::context::{Cx, CxTestBuilder};
use uuid::Uuid;
use crate::common::{
body_string, field_error, get, input_value, memory_db, mount, panel, panel_router, post_fields,
refusal,
};
#[derive(Debug, Clone, toasty::Model)]
struct Item {
#[key]
#[auto]
id: Uuid,
title: String,
notes: String,
priority: i64,
done: bool,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Item)]
struct ItemForm {
title: String,
notes: String,
#[form(blank = 3)]
priority: i64,
#[form(blank = false)]
done: bool,
}
fn item_schema() -> Schema {
Schema::new((
Field::text(Item::fields().title()),
Field::text(Item::fields().notes()).optional(),
Field::text(Item::fields().priority()).optional(),
Field::choice(Item::fields().done())
.options(vec!["true".to_string(), "false".to_string()])
.optional(),
))
}
fn item_table() -> Table<Item> {
Table::new(TextColumn::new(lens!(Item.title)))
}
struct ItemResource;
impl Resource for ItemResource {
type Model = Item;
type Form = ItemForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(
ability,
Ability::ViewAny | Ability::View(_) | Ability::Create | Ability::Update(_)
)
})
.table(item_table())
.form(item_schema())
.view(Schema::new(Field::text(Item::fields().title())))
}
fn validate_record(_cx: &Cx, form: &ItemForm) -> FieldErrors {
let mut errors = FieldErrors::new();
if form.priority > 10 {
errors.add("priority", "Priority is at most 10");
}
errors
}
}
async fn item_db() -> Db {
memory_db(toasty::models!(Item)).await
}
async fn seed_item(db: &Db) -> Item {
let mut db = db.clone();
toasty::create!(Item {
title: "Stored".to_string(),
notes: "keep me".to_string(),
priority: 7,
done: true,
})
.exec(&mut db)
.await
.expect("seed item")
}
async fn reload(db: &Db, id: Uuid) -> Item {
let mut db = db.clone();
Item::get_by_id(&mut db, &id)
.await
.expect("the item exists")
}
fn cx_for(db: &Db) -> Cx {
CxTestBuilder::new().app_context(db.clone()).build()
}
fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
#[tokio::test]
#[should_panic(expected = "RecvError")]
async fn toasty_refuses_an_update_with_no_assignment() {
let db = item_db().await;
let mut record = seed_item(&db).await;
let mut db = db.clone();
let _ = record.update().exec(&mut db).await;
}
#[tokio::test]
async fn the_derived_update_reloads_the_record_it_wrote() {
let db = item_db().await;
let mut record = seed_item(&db).await;
let form = ItemForm {
title: "Written".to_string(),
notes: String::new(),
priority: 1,
done: false,
};
let named = HashSet::from([ItemFormField::Title]);
let update = form
.into_update(&mut record, &named)
.expect("a named field builds an update");
let mut handle = db.clone();
update.exec(&mut handle).await.expect("the update runs");
assert_eq!(record.title, "Written", "the record holds the written row");
let stored = reload(&db, record.id).await;
assert_eq!(stored.title, "Written");
assert_eq!(stored.notes, "keep me", "unnamed fields are not assigned");
assert_eq!(stored.priority, 7);
}
#[tokio::test]
async fn into_update_is_none_when_no_field_is_named() {
let db = item_db().await;
let mut record = seed_item(&db).await;
let form = ItemForm {
title: "x".to_string(),
notes: String::new(),
priority: 1,
done: false,
};
assert!(form.into_update(&mut record, &HashSet::new()).is_none());
}
#[tokio::test]
async fn blank_keys_take_each_fields_blank_answer() {
let db = item_db().await;
let cx = cx_for(&db);
let form = ItemForm::parse(
&cx,
&map(&[("title", " "), ("notes", ""), ("priority", "")]),
)
.expect("every field answers blank");
assert_eq!(form.title, "", "a `String` answers the empty string");
assert_eq!(form.priority, 3, "a declared blank answers itself");
assert!(!form.done, "an absent key reads as blank");
}
#[tokio::test]
async fn a_blank_with_no_answer_and_a_bad_value_are_refused_by_key() {
#[derive(tablo_core::RecordForm)]
#[form(model = Item)]
struct StrictForm {
priority: i64,
done: bool,
}
let db = item_db().await;
let cx = cx_for(&db);
let Err(errors) = StrictForm::parse(&cx, &map(&[("priority", ""), ("done", "maybe")])) else {
panic!("both fields fail");
};
assert_eq!(
errors.len(),
2,
"every failing key reports once: {errors:?}"
);
assert_eq!(errors[0].key, "priority");
assert_eq!(errors[0].kind, FieldErrorKind::Required);
assert_eq!(errors[1].key, "done");
assert_eq!(errors[1].kind, FieldErrorKind::Invalid);
assert_eq!(errors[1].message, "`maybe` is not a valid yes/no value");
}
#[tokio::test]
async fn hydrate_is_the_parse_s_inverse() {
let db = item_db().await;
let cx = cx_for(&db);
let record = seed_item(&db).await;
let values = ItemForm::hydrate(&cx, &record);
assert_eq!(values["title"], "Stored");
assert_eq!(values["notes"], "keep me");
assert_eq!(values["priority"], "7");
assert_eq!(values["done"], "true");
let form = ItemForm::parse(&cx, &values).expect("a hydrated record parses");
assert_eq!(form.priority, 7);
assert!(form.done);
}
#[tokio::test]
async fn a_create_writes_the_parsed_form() {
let db = item_db().await;
let router = panel_router::<ItemResource>(db.clone());
let response = post_fields(&router, "/admin/items/create", &[("title", "New")]).await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let mut handle = db.clone();
let items = Item::all().exec(&mut handle).await.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "New");
assert_eq!(items[0].notes, "");
assert_eq!(
items[0].priority, 3,
"an absent optional key takes its blank answer"
);
assert!(!items[0].done);
}
#[tokio::test]
async fn an_edit_writes_only_the_fields_it_names() {
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<ItemResource>(db.clone());
let response = post_fields(
&router,
&format!("/admin/items/{}/edit", item.id),
&[("priority", "9")],
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let stored = reload(&db, item.id).await;
assert_eq!(stored.priority, 9);
assert_eq!(
stored.title, "Stored",
"an omitted required key keeps its value"
);
assert_eq!(stored.notes, "keep me");
assert!(stored.done);
}
#[tokio::test]
async fn an_emptied_control_stores_its_blank_answer() {
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<ItemResource>(db.clone());
let response = post_fields(
&router,
&format!("/admin/items/{}/edit", item.id),
&[("notes", ""), ("priority", ""), ("done", "")],
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let stored = reload(&db, item.id).await;
assert_eq!(stored.notes, "", "an emptied `String` clears");
assert_eq!(stored.priority, 3);
assert!(!stored.done);
assert_eq!(stored.title, "Stored");
}
#[tokio::test]
async fn an_edit_naming_no_field_writes_nothing_and_redirects() {
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<ItemResource>(db.clone());
let response = post_fields(&router, &format!("/admin/items/{}/edit", item.id), &[]).await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let stored = reload(&db, item.id).await;
assert_eq!(stored.title, "Stored");
assert_eq!(stored.priority, 7);
}
#[tokio::test]
async fn schema_and_record_errors_render_in_one_round() {
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<ItemResource>(db.clone());
let response = post_fields(
&router,
&format!("/admin/items/{}/edit", item.id),
&[("title", ""), ("priority", "11")],
)
.await;
assert_eq!(response.status(), StatusCode::OK, "the form re-renders");
let html = body_string(response).await;
assert_eq!(
field_error(&html, "title").as_deref(),
Some("Title is required"),
"{html}"
);
assert_eq!(
field_error(&html, "priority").as_deref(),
Some("Priority is at most 10"),
"{html}"
);
let stored = reload(&db, item.id).await;
assert_eq!(stored.priority, 7, "a refused submission writes nothing");
}
#[tokio::test]
async fn a_repeater_label_keyed_rule_renders_in_the_group() {
struct Tagged;
impl Resource for Tagged {
type Model = Item;
type Form = ItemForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(
ability,
Ability::ViewAny | Ability::View(_) | Ability::Update(_)
)
})
.table(item_table())
.form(Schema::new((
Field::text(Item::fields().title()),
Repeater::new("Tags").schema((
Field::text(Item::fields().notes()).optional(),
Field::text(Item::fields().priority()).optional(),
Field::choice(Item::fields().done())
.options(vec!["true".to_string(), "false".to_string()])
.optional(),
)),
)))
}
fn validate_record(_cx: &Cx, _form: &ItemForm) -> FieldErrors {
let mut errors = FieldErrors::new();
errors.add("Tags", "At least one tag");
errors
}
}
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<Tagged>(db.clone());
let response = post_fields(&router, &format!("/admin/items/{}/edit", item.id), &[]).await;
assert_eq!(response.status(), StatusCode::OK, "the form re-renders");
let html = body_string(response).await;
assert_eq!(
field_error(&html, "tags").as_deref(),
Some("At least one tag"),
"{html}"
);
assert_eq!(reload(&db, item.id).await.priority, 7, "nothing is written");
}
#[tokio::test]
async fn the_detail_page_reads_the_forms_projection() {
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<ItemResource>(db.clone());
let response = get(&router, &format!("/admin/items/{}", item.id)).await;
assert_eq!(response.status(), StatusCode::OK);
let html = body_string(response).await;
assert!(html.contains("Stored"), "{html}");
}
#[derive(Debug, Clone, toasty::Model)]
struct Owned {
#[key]
#[auto]
id: Uuid,
#[index]
tenant_id: Uuid,
title: String,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Owned)]
struct OwnedForm {
title: String,
}
struct OwnedResource;
impl Resource for OwnedResource {
type Model = Owned;
type Form = OwnedForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("owned")
.policy(|_cx: &Cx, ability: Ability<'_, Owned>| {
matches!(ability, Ability::ViewAny | Ability::Create)
})
.tenancy(Tenancy::column(Owned::fields().tenant_id()))
.table(Table::new(TextColumn::new(lens!(Owned.title))))
.form(Schema::new(Field::text(Owned::fields().title())))
}
}
#[tokio::test]
async fn the_derived_create_stamps_the_request_tenant() {
let db = memory_db(toasty::models!(Owned)).await;
let tenant = Uuid::new_v4();
let cx = CxTestBuilder::new()
.app_context(db.clone())
.request_context(Tenant(tenant))
.build();
let mut handle = db.clone();
let created = write_create::<OwnedResource>(
&cx,
OwnedForm {
title: "Mine".to_string(),
},
&mut handle,
)
.await
.expect("the create runs");
assert_eq!(created.tenant_id, tenant, "the request tenant is stamped");
}
fn refused<R: Resource>(db: Db) -> Vec<DeclarationErrorKind> {
refusal(mount(db, panel().resource::<R>()))
.into_iter()
.map(|error| error.kind)
.collect()
}
macro_rules! item_resource {
($name:ident, $form:ty, $schema:expr) => {
struct $name;
impl Resource for $name {
type Model = Item;
type Form = $form;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(ability, Ability::ViewAny)
})
.table(item_table())
.form($schema)
}
}
};
}
#[derive(tablo_core::RecordForm)]
#[form(model = Item)]
struct TitleForm {
title: String,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Item)]
struct PriorityForm {
priority: i64,
}
#[tokio::test]
async fn build_refuses_a_control_no_field_binds() {
item_resource!(
Unbound,
TitleForm,
Schema::new((
Field::text(Item::fields().title()),
Field::text(Item::fields().notes()),
))
);
let errors = refused::<Unbound>(item_db().await);
assert!(
errors.contains(&DeclarationErrorKind::UnboundControl {
control: "notes".to_string()
}),
"{errors:?}"
);
}
#[tokio::test]
async fn build_refuses_a_field_no_control_declares() {
item_resource!(
Unclaimed,
ItemForm,
Schema::new(Field::text(Item::fields().title()))
);
let errors = refused::<Unclaimed>(item_db().await);
assert!(
errors.contains(&DeclarationErrorKind::MissingControl {
field: "notes".to_string(),
key: "notes".to_string(),
}),
"{errors:?}"
);
}
#[tokio::test]
async fn build_refuses_an_optional_control_with_no_blank_answer() {
item_resource!(
Unanswered,
PriorityForm,
Schema::new(Field::text(Item::fields().priority()).optional())
);
let errors = refused::<Unanswered>(item_db().await);
assert!(
errors.contains(&DeclarationErrorKind::NoBlankAnswer {
control: "priority".to_string(),
field: "priority".to_string(),
in_repeater: false,
}),
"{errors:?}"
);
}
#[tokio::test]
async fn build_refuses_a_repeater_control_with_no_blank_answer() {
item_resource!(
Repeated,
PriorityForm,
Schema::new(Repeater::new("Priorities").schema(Field::text(Item::fields().priority())))
);
let errors = refused::<Repeated>(item_db().await);
assert!(
errors.contains(&DeclarationErrorKind::NoBlankAnswer {
control: "priority".to_string(),
field: "priority".to_string(),
in_repeater: true,
}),
"{errors:?}"
);
}
#[tokio::test]
async fn build_refuses_a_shared_leaf_with_no_blank_answer() {
#[derive(Debug, Clone, PartialEq, toasty::Embed, tablo_core::EmbeddedForm)]
enum Life {
#[column(variant = 1)]
Draft { note: String },
#[column(variant = 2)]
Live {
#[shared(stamp)]
published_at: i64,
},
}
#[derive(Debug, Clone, toasty::Model)]
struct Dated {
#[key]
#[auto]
id: Uuid,
title: String,
life: Life,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Dated)]
struct DatedForm {
#[form(embed)]
life: Life,
}
struct DatedResource;
impl Resource for DatedResource {
type Model = Dated;
type Form = DatedForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("dated")
.table(Table::new(TextColumn::new(lens!(Dated.title))))
.form(Schema::new(Life::form(Dated::fields().life())))
}
}
let errors = refused::<DatedResource>(memory_db(toasty::models!(Dated)).await);
assert!(
errors.iter().any(
|error| matches!(error, DeclarationErrorKind::NoBlankAnswer {
control,
in_repeater: false,
..
} if control == "life_stamp")
),
"{errors:?}"
);
}
#[tokio::test]
async fn build_refuses_a_repeater_held_variant_payload_without_an_answer() {
#[derive(Debug, Clone, PartialEq, toasty::Embed, tablo_core::EmbeddedForm)]
enum Body {
#[column(variant = 1)]
Text { note: String },
#[column(variant = 2)]
Video { seconds: i64 },
}
#[derive(Debug, Clone, toasty::Model)]
struct Clip {
#[key]
#[auto]
id: Uuid,
title: String,
body: Body,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Clip)]
struct ClipForm {
#[form(embed)]
body: Body,
}
struct ClipResource;
impl Resource for ClipResource {
type Model = Clip;
type Form = ClipForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("clips")
.table(Table::new(TextColumn::new(lens!(Clip.title))))
.form(Schema::new(
Repeater::new("Clips").schema(Body::form(Clip::fields().body())),
))
}
}
let errors = refused::<ClipResource>(memory_db(toasty::models!(Clip)).await);
assert!(
errors.iter().any(
|error| matches!(error, DeclarationErrorKind::NoBlankAnswer {
field,
in_repeater: true,
..
} if field == "body")
),
"{errors:?}"
);
}
#[tokio::test]
async fn build_refuses_a_gated_form_claiming_the_tenant_column() {
#[derive(tablo_core::RecordForm)]
#[form(model = Owned)]
struct ClaimingForm {
tenant_id: Uuid,
title: String,
}
struct Claiming;
impl Resource for Claiming {
type Model = Owned;
type Form = ClaimingForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.tenancy(Tenancy::column(Owned::fields().tenant_id()))
.table(Table::new(TextColumn::new(lens!(Owned.title))))
.form(Schema::new((
Field::text(Owned::fields().tenant_id()),
Field::text(Owned::fields().title()),
)))
}
}
let errors = refused::<Claiming>(memory_db(toasty::models!(Owned)).await);
assert!(
errors.contains(&DeclarationErrorKind::FormClaimsTenantColumn {
field: "tenant_id".to_string(),
column: "tenant_id".to_string(),
}),
"{errors:?}"
);
}
macro_rules! list_only_resource {
($name:ident, $create:expr, $schema:expr) => {
struct $name;
impl Resource for $name {
type Model = Item;
type Form = NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| match ability {
Ability::Create => $create,
_ => false,
})
.table(item_table())
.form($schema)
}
}
};
}
#[tokio::test]
async fn build_refuses_a_list_only_resource_that_allows_create() {
list_only_resource!(Creating, true, Schema::empty());
assert_eq!(
refused::<Creating>(item_db().await),
[DeclarationErrorKind::CreateWithoutForm]
);
}
#[tokio::test]
async fn build_refuses_a_list_only_resource_that_declares_a_schema() {
list_only_resource!(Schematic, false, item_schema());
assert_eq!(
refused::<Schematic>(item_db().await),
[DeclarationErrorKind::FormWithoutRecordForm]
);
}
#[tokio::test]
async fn build_refuses_an_empty_form_override() {
item_resource!(Emptied, TitleForm, Schema::empty());
assert_eq!(
refused::<Emptied>(item_db().await),
[DeclarationErrorKind::EmptyFormOverride]
);
}
#[tokio::test]
async fn a_list_only_resource_serves_no_form_route() {
list_only_resource!(Listed, false, Schema::empty());
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<Listed>(db.clone());
let edit = format!("/admin/items/{}/edit", item.id);
for (response, status) in [
(
get(&router, "/admin/items/create").await,
StatusCode::NOT_FOUND,
),
(
get(&router, "/admin/items/options?field=title").await,
StatusCode::NOT_FOUND,
),
(get(&router, &edit).await, StatusCode::NOT_FOUND),
(
post_fields(&router, "/admin/items/create", &[("title", "New")]).await,
StatusCode::METHOD_NOT_ALLOWED,
),
(
post_fields(&router, &edit, &[("title", "New")]).await,
StatusCode::NOT_FOUND,
),
] {
assert_eq!(response.status(), status);
}
assert_eq!(reload(&db, item.id).await.title, "Stored");
}
#[tokio::test]
async fn a_list_only_detail_page_reads_view_values() {
struct Viewed;
impl Resource for Viewed {
type Model = Item;
type Form = NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| matches!(ability, Ability::View(_)))
.table(item_table())
.view(Schema::new(Field::text(Item::fields().title())))
}
fn view_values(_cx: &Cx, record: &Item) -> HashMap<String, String> {
HashMap::from([("title".to_string(), format!("{} (view)", record.title))])
}
}
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<Viewed>(db);
let response = get(&router, &format!("/admin/items/{}", item.id)).await;
assert_eq!(response.status(), StatusCode::OK);
let html = body_string(response).await;
assert!(html.contains("Stored (view)"), "{html}");
}
#[tokio::test]
async fn a_form_resource_serves_create_and_edit() {
let db = item_db().await;
let item = seed_item(&db).await;
let router: topcoat::router::Router = mount(
db,
Panel::new("admin")
.auth(tablo_core::Auth::disabled())
.resource::<ItemResource>(),
)
.expect("panel builds");
let create = get(&router, "/admin/items/create").await;
assert_eq!(create.status(), StatusCode::OK);
let html = body_string(create).await;
for name in ["title", "notes", "priority", "done"] {
assert!(
html.contains(&format!("name=\"{name}\"")),
"`{name}` posts: {html}"
);
}
assert_eq!(
get(&router, &format!("/admin/items/{}/edit", item.id))
.await
.status(),
StatusCode::OK
);
}
macro_rules! title_only_resource {
($name:ident, $columns:expr) => {
struct $name;
impl Resource for $name {
type Model = Item;
type Form = TitleForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(ability, Ability::Create)
})
.table(item_table())
.form(Schema::new(Field::text(Item::fields().title())))
.create_columns($columns)
}
}
};
}
#[tokio::test]
async fn build_refuses_a_create_that_leaves_a_required_column_unset() {
title_only_resource!(Partial, []);
let errors = refused::<Partial>(item_db().await);
assert!(
errors.contains(&DeclarationErrorKind::UnwrittenColumn {
column: "notes".to_string()
}),
"{errors:?}"
);
}
#[tokio::test]
async fn create_columns_names_what_an_override_sets() {
title_only_resource!(Covered, ["notes", "priority", "done"]);
mount(item_db().await, panel().resource::<Covered>())
.expect("the override's own columns are declared");
title_only_resource!(Misnamed, ["notes", "priority", "done", "nope"]);
assert_eq!(
refused::<Misnamed>(item_db().await),
[DeclarationErrorKind::UnknownCreateColumn { column: "nope" }]
);
}
#[tokio::test]
async fn a_value_the_form_type_refuses_renders_inline() {
struct Loose;
impl Resource for Loose {
type Model = Item;
type Form = PriorityForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(ability, Ability::View(_) | Ability::Update(_))
})
.table(item_table())
.form(
Schema::new(
Field::choice(Item::fields().priority())
.options(vec!["1".to_string(), "lots".to_string()]),
),
)
}
}
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<Loose>(db.clone());
let response = post_fields(
&router,
&format!("/admin/items/{}/edit", item.id),
&[("priority", "lots")],
)
.await;
assert_eq!(response.status(), StatusCode::OK, "the form re-renders");
let html = body_string(response).await;
assert_eq!(
field_error(&html, "priority").as_deref(),
Some("`lots` is not a valid whole number"),
"{html}"
);
assert_eq!(reload(&db, item.id).await.priority, 7);
}
#[tokio::test]
async fn an_unkeyable_record_rule_fails_closed() {
struct Keyless(PriorityForm);
impl RecordForm for Keyless {
type Model = Item;
type Field = PriorityFormField;
fn fields() -> Vec<tablo_core::FormField<PriorityFormField>> {
Vec::new()
}
fn hydrate(cx: &Cx, record: &Item) -> HashMap<String, String> {
PriorityForm::hydrate(cx, record)
}
fn parse(
_cx: &Cx,
_values: &HashMap<String, String>,
) -> Result<Self, Vec<tablo_core::FieldError>> {
Ok(Keyless(PriorityForm { priority: 1 }))
}
fn into_create(self) -> <Item as toasty::schema::Model>::Create {
self.0.into_create()
}
fn into_update<'a>(
self,
record: &'a mut Item,
named: &HashSet<PriorityFormField>,
) -> Option<<Item as toasty::schema::Model>::Update<'a>> {
self.0.into_update(record, named)
}
fn exec_update<'a>(
update: <Item as toasty::schema::Model>::Update<'a>,
ex: &'a mut dyn toasty::Executor,
) -> impl std::future::Future<Output = toasty::Result<()>> + Send + 'a {
PriorityForm::exec_update(update, ex)
}
}
struct Refusing;
impl Resource for Refusing {
type Model = Item;
type Form = Keyless;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(ability, Ability::View(_) | Ability::Update(_))
})
.table(item_table())
.form(Schema::empty())
}
fn validate_record(_cx: &Cx, _form: &Keyless) -> FieldErrors {
let mut errors = FieldErrors::new();
errors.add("priority", "never");
errors
}
}
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<Refusing>(db.clone());
let response = post_fields(&router, &format!("/admin/items/{}/edit", item.id), &[]).await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(reload(&db, item.id).await.priority, 7, "nothing is written");
}
#[tokio::test]
async fn an_unkeyable_parse_failure_fails_closed() {
struct Unkeyable(PriorityForm);
impl RecordForm for Unkeyable {
type Model = Item;
type Field = PriorityFormField;
fn fields() -> Vec<tablo_core::FormField<PriorityFormField>> {
Vec::new()
}
fn hydrate(cx: &Cx, record: &Item) -> HashMap<String, String> {
PriorityForm::hydrate(cx, record)
}
fn parse(
_cx: &Cx,
_values: &HashMap<String, String>,
) -> Result<Self, Vec<tablo_core::FieldError>> {
Err(vec![tablo_core::FieldError::invalid(
"never",
"never renders",
)])
}
fn into_create(self) -> <Item as toasty::schema::Model>::Create {
self.0.into_create()
}
fn into_update<'a>(
self,
record: &'a mut Item,
named: &HashSet<PriorityFormField>,
) -> Option<<Item as toasty::schema::Model>::Update<'a>> {
self.0.into_update(record, named)
}
fn exec_update<'a>(
update: <Item as toasty::schema::Model>::Update<'a>,
ex: &'a mut dyn toasty::Executor,
) -> impl std::future::Future<Output = toasty::Result<()>> + Send + 'a {
PriorityForm::exec_update(update, ex)
}
}
struct Parseless;
impl Resource for Parseless {
type Model = Item;
type Form = Unkeyable;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|_cx: &Cx, ability: Ability<'_, Item>| {
matches!(ability, Ability::View(_) | Ability::Update(_))
})
.table(item_table())
.form(Schema::empty())
}
}
let db = item_db().await;
let item = seed_item(&db).await;
let router = panel_router::<Parseless>(db.clone());
let response = post_fields(&router, &format!("/admin/items/{}/edit", item.id), &[]).await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(reload(&db, item.id).await.priority, 7, "nothing is written");
}
#[tokio::test]
async fn a_list_only_resource_never_links_to_create() {
struct TenantCreates;
impl Resource for TenantCreates {
type Model = Item;
type Form = tablo_core::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("items")
.policy(|cx: &Cx, ability: Ability<'_, Item>| match ability {
Ability::ViewAny => true,
Ability::Create => tablo_core::tenant_id(cx).is_some(),
_ => false,
})
.table(item_table())
}
}
let router = mount(item_db().await, panel().resource::<TenantCreates>())
.expect("create is denied without a request");
let request = http::Request::builder()
.uri("/admin/items")
.extension(Tenant(Uuid::new_v4()))
.body(topcoat::router::Body::empty())
.unwrap();
let response = router.handle(request).await;
assert_eq!(response.status(), StatusCode::OK);
let html = body_string(response).await;
assert!(!html.contains("/admin/items/create"), "{html}");
}
#[tokio::test]
async fn the_derived_default_form_renders_and_writes() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, tablo_core::Options)]
enum WidgetRole {
Admin,
Member,
}
assert_eq!(WidgetRole::Admin.value(), "admin");
#[derive(Debug, Clone, toasty::Model)]
struct Widget {
#[key]
#[auto]
id: Uuid,
name: String,
role: String,
active: bool,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Widget)]
struct WidgetForm {
name: String,
#[form(options = WidgetRole, blank = WidgetRole::Member.value())]
role: String,
active: bool,
}
struct WidgetResource;
impl Resource for WidgetResource {
type Model = Widget;
type Form = WidgetForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("widgets")
.policy(|_cx: &Cx, ability: Ability<'_, Widget>| {
matches!(
ability,
Ability::ViewAny | Ability::View(_) | Ability::Create | Ability::Update(_)
)
})
.table(Table::new(TextColumn::new(lens!(Widget.name))))
}
}
let db = memory_db(toasty::models!(Widget)).await;
let router = panel_router::<WidgetResource>(db.clone());
let create = get(&router, "/admin/widgets/create").await;
assert_eq!(create.status(), StatusCode::OK);
let html = body_string(create).await;
for name in ["name", "role", "active"] {
assert!(
html.contains(&format!("name=\"{name}\"")),
"`{name}` posts: {html}"
);
}
assert!(
html.find("name=\"name\"").unwrap() < html.find("name=\"role\"").unwrap()
&& html.find("name=\"role\"").unwrap() < html.find("name=\"active\"").unwrap(),
"the default schema keeps declaration order: {html}"
);
assert!(
html.contains("value=\"admin\"") && html.contains("value=\"member\""),
"the choice carries the Options list: {html}"
);
let response = post_fields(
&router,
"/admin/widgets/create",
&[("name", "New"), ("role", "admin")],
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let mut handle = db.clone();
let rows = Widget::all().exec(&mut handle).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "New");
assert_eq!(rows[0].role, "admin");
assert!(!rows[0].active, "an absent toggle reads as false");
let edit = get(&router, &format!("/admin/widgets/{}/edit", rows[0].id)).await;
assert_eq!(edit.status(), StatusCode::OK);
let html = body_string(edit).await;
assert_eq!(
input_value(&html, "name").as_deref(),
Some("New"),
"the edit form hydrates the stored value: {html}"
);
let response = post_fields(
&router,
&format!("/admin/widgets/{}/edit", rows[0].id),
&[("active", "true")],
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let mut handle = db.clone();
let stored = Widget::get_by_id(&mut handle, &rows[0].id)
.await
.expect("the widget exists");
assert!(stored.active);
assert_eq!(stored.name, "New", "an unposted key keeps its value");
assert_eq!(stored.role, "admin", "an unposted key keeps its value");
}
#[derive(Debug, Clone, toasty::Model)]
struct Ticket {
#[key]
#[auto]
id: Uuid,
subject: String,
status: String,
urgent: bool,
estimate: i64,
}
#[derive(tablo_core::Options)]
enum TicketStatus {
Open,
#[option(label = "Waiting on customer")]
Waiting,
}
#[derive(tablo_core::RecordForm)]
#[form(model = Ticket)]
struct TicketForm {
subject: String,
#[form(options = TicketStatus)]
status: String,
urgent: bool,
estimate: i64,
}
struct TicketResource;
impl Resource for TicketResource {
type Model = Ticket;
type Form = TicketForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new().policy(tablo_core::ReadOnly)
}
}
struct UnviewedTicketResource;
impl Resource for UnviewedTicketResource {
type Model = Ticket;
type Form = TicketForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.policy(tablo_core::ReadOnly)
.view(Schema::empty())
}
}
async fn ticket_db() -> (Db, Ticket) {
let mut db = memory_db(toasty::models!(Ticket)).await;
let ticket = toasty::create!(Ticket {
subject: "Printer jam".to_string(),
status: TicketStatus::Waiting.value().to_string(),
urgent: true,
estimate: 3,
})
.exec(&mut db)
.await
.expect("seed ticket");
toasty::create!(Ticket {
subject: "New laptop".to_string(),
status: TicketStatus::Open.value().to_string(),
urgent: false,
estimate: 8,
})
.exec(&mut db)
.await
.expect("seed ticket");
(db, ticket)
}
#[tokio::test]
async fn the_record_form_derives_the_table() {
let (db, _) = ticket_db().await;
let router = panel_router::<TicketResource>(db);
let html = body_string(get(&router, "/admin/tickets?sort=estimate&dir=desc").await).await;
for header in ["Subject", "Status", "Urgent", "Estimate"] {
assert!(html.contains(header), "a {header} column: {html}");
}
assert!(
html.contains("Waiting on customer") && !html.contains(">waiting<"),
"an options field shows its label: {html}"
);
assert!(html.contains("Yes") && html.contains("No"), "{html}");
assert!(
html.find("New laptop").unwrap() < html.find("Printer jam").unwrap(),
"sorted by the estimate, descending: {html}"
);
let searched = body_string(get(&router, "/admin/tickets?q=printer").await).await;
assert!(
searched.contains("Printer jam") && !searched.contains("New laptop"),
"searched by the subject: {searched}"
);
}
#[tokio::test]
async fn the_view_defaults_to_the_form() {
let (db, ticket) = ticket_db().await;
let viewed = panel_router::<TicketResource>(db.clone());
let detail = get(&viewed, &format!("/admin/tickets/{}", ticket.id)).await;
assert_eq!(detail.status(), StatusCode::OK);
let detail = body_string(detail).await;
assert!(
detail.contains("Printer jam"),
"the form's fields, read-only: {detail}"
);
assert_eq!(
input_value(&detail, "subject"),
None,
"the detail page renders no control for the field: {detail}"
);
let unviewed = panel_router::<UnviewedTicketResource>(db);
let detail = get(&unviewed, &format!("/admin/unviewed-tickets/{}", ticket.id)).await;
assert_eq!(detail.status(), StatusCode::NOT_FOUND);
}