use super::*;
use crate::{
Ability, ResourceDef, lens,
panel::test_support::{Dummy, dummy_table, mount, panel_for},
test_support::memory_db,
};
async fn list_html<R: Resource>() -> String {
list_html_with::<R>(&["Ada"]).await
}
async fn list_html_with<R: Resource>(names: &[&str]) -> String {
list_html_via(names, panel_for::<R>).await
}
async fn list_html_via(names: &[&str], panel: fn() -> crate::Panel) -> String {
use http_body_util::BodyExt;
let mut db = memory_db(toasty::models!(Dummy)).await;
for name in names {
toasty::create!(Dummy {
name: (*name).to_string(),
})
.exec(&mut db)
.await
.unwrap();
}
let router = mount(db, panel()).expect("panel builds");
let resp = router
.handle(
http::Request::builder()
.uri("/admin/dummies")
.body(Body::empty())
.unwrap(),
)
.await;
assert!(resp.status().is_success());
let body = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&body).to_string()
}
#[tokio::test]
async fn two_lists_declare_distinct_signal_ids() {
use http_body_util::BodyExt;
use crate::resource::Resource;
macro_rules! list_resource {
($name:ident, $slug:literal) => {
struct $name;
impl Resource for $name {
type Model = Dummy;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug($slug)
.policy_fn(|_cx, ability| matches!(ability, Ability::ViewAny))
.table(dummy_table().paginate(25))
}
}
};
}
list_resource!(FirstResource, "firsts");
list_resource!(SecondResource, "seconds");
let db = memory_db(toasty::models!(Dummy)).await;
fn bare<'a>(_cx: &'a Cx, slot: topcoat::router::Slot<'a>) -> topcoat::view::BoxView<'a> {
topcoat::view::ViewExt::boxed(slot)
}
let router = mount(
db,
crate::Panel::new("admin")
.resource::<FirstResource>()
.resource::<SecondResource>()
.auth(crate::Auth::disabled())
.layout(bare),
)
.expect("panel builds");
let mut ids = Vec::new();
for uri in ["/admin/firsts", "/admin/seconds"] {
let resp = router
.handle(
http::Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await;
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body).to_string();
let page: std::collections::HashSet<String> = html
.split(""id":"")
.skip(1)
.filter_map(|rest| rest.split(""").next().map(str::to_string))
.collect();
assert!(
!page.is_empty(),
"{uri} must declare its table signals, got {html}"
);
ids.push(page);
}
assert!(
ids[0].is_disjoint(&ids[1]),
"two lists share signal ids {:?}",
ids[0].intersection(&ids[1]).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn a_rerun_renders_the_query_its_signal_carries() {
use http_body_util::BodyExt;
struct SearchResource;
impl Resource for SearchResource {
type Model = Dummy;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("dummies")
.policy_fn(|_cx, ability| matches!(ability, Ability::ViewAny))
.table(crate::table::Table::new(
crate::table::TextColumn::new(lens!(Dummy.name)).searchable(),
))
}
}
let mut db = memory_db(toasty::models!(Dummy)).await;
for name in ["Ada", "Bob"] {
toasty::create!(Dummy {
name: name.to_string(),
})
.exec(&mut db)
.await
.unwrap();
}
let router = mount(db, panel_for::<SearchResource>()).expect("panel builds");
let read = |resp: http::Response<Body>| async move {
let body = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&body).to_string()
};
let html = read(
router
.handle(
http::Request::builder()
.uri("/admin/dummies?q=Bob")
.body(Body::empty())
.unwrap(),
)
.await,
)
.await;
assert!(html.contains(">Bob<") && !html.contains(">Ada<"), "{html}");
let declaration = html
.split("::topcoat::signal(")
.skip(1)
.find(|declaration| declaration.contains(""v":"q=Bob""))
.unwrap_or_else(|| panic!("the page must declare its query signal, got {html}"));
let id = declaration
.split(""id":"")
.nth(1)
.and_then(|rest| rest.split(""").next())
.expect("a signal id");
let rerun = read(
router
.handle(
http::Request::builder()
.method(http::Method::POST)
.uri("/admin/dummies?q=Bob")
.header("X-Topcoat-Runtime", "true")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"signals":{{"{id}":"q=Ada"}}}}"#)))
.unwrap(),
)
.await,
)
.await;
assert!(
rerun.contains(">Ada<") && !rerun.contains(">Bob<"),
"{rerun}"
);
}
#[tokio::test]
async fn read_only_resource_hides_delete_chrome() {
use http_body_util::BodyExt;
use crate::resource::Resource;
struct ReadOnlyResource;
impl Resource for ReadOnlyResource {
type Model = Dummy;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("dummies")
.policy_fn(|_cx, ability| matches!(ability, Ability::ViewAny))
.table(dummy_table().paginate(25))
}
}
let mut db = memory_db(toasty::models!(Dummy)).await;
toasty::create!(Dummy {
name: "Ada".to_string(),
})
.exec(&mut db)
.await
.unwrap();
let router = mount(db, panel_for::<ReadOnlyResource>()).expect("panel builds");
let resp = router
.handle(
http::Request::builder()
.uri("/admin/dummies")
.body(Body::empty())
.unwrap(),
)
.await;
assert!(resp.status().is_success());
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Ada"),
"the grid must render the seeded row, or the negative assertions below are vacuous, got {html}"
);
assert!(
!html.contains("Delete selected"),
"read-only list must not render bulk chrome, got {html}"
);
assert!(
!html.contains("/delete"),
"read-only list must not render delete actions, got {html}"
);
assert!(
!html.contains("/edit") && !html.contains(">Edit<"),
"read-only list must not render edit actions, got {html}"
);
}
#[tokio::test]
async fn non_editable_resource_hides_edit_links() {
use crate::resource::Resource;
struct WritableResource;
impl Resource for WritableResource {
type Model = Dummy;
type Form = WritableForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("dummies")
.policy_fn(|_cx, ability| {
matches!(
ability,
Ability::ViewAny | Ability::View(_) | Ability::Create | Ability::Update(_)
)
})
.table(dummy_table().paginate(25))
}
}
#[derive(crate::RecordForm)]
#[form(model = Dummy)]
struct WritableForm {
name: String,
}
struct DeniedResource;
impl Resource for DeniedResource {
type Model = Dummy;
type Form = WritableForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("dummies")
.policy_fn(|_cx, ability| matches!(ability, Ability::ViewAny))
.table(dummy_table().paginate(25))
}
}
struct LockedResource;
impl Resource for LockedResource {
type Model = Dummy;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("dummies")
.policy_fn(|_cx, ability| matches!(ability, Ability::ViewAny))
.table(dummy_table().paginate(25))
}
}
let html = list_html::<WritableResource>().await;
assert!(
html.contains("/edit") && html.contains("aria-label=\"Edit\""),
"editable list must link rows to their edit pages, got {html}"
);
assert!(
html.contains("data-topcoat-link=\"never\"")
&& !html.contains("data-topcoat-link=\"intent\""),
"panel links must use runtime navigation without prefetch, got {html}"
);
for html in [
list_html::<LockedResource>().await,
list_html::<DeniedResource>().await,
] {
assert!(
html.contains("Ada"),
"the row renders, or the assertion below is vacuous, got {html}"
);
assert!(
!html.contains("/edit") && !html.contains(">Edit<"),
"a list whose rows cannot be edited renders no edit link, got {html}"
);
}
}
#[test]
fn retry_url_for_error_drops_only_bad_cursors() {
let state = TableState {
search: Some("Ada".to_string()),
cursor: Some(crate::table::Cursor::After("cur".to_string())),
..TableState::default()
};
let bad_cursor =
crate::toasty_compat::cursor::decode("zz").expect_err("malformed cursor must fail");
let retry = retry_url_for_error(&state, &bad_cursor, "/admin/users");
assert!(
!retry.contains("after="),
"bad-cursor retry must drop pagination, got {retry}"
);
assert!(
retry.contains("q=Ada"),
"bad-cursor retry keeps the other state, got {retry}"
);
let db_error = crate::error::unavailable("connection reset");
let retry = retry_url_for_error(&state, &db_error, "/admin/users");
assert!(
retry.contains("after=cur"),
"transient failures retry the same evidence, got {retry}"
);
}