use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use tower::ServiceExt;
use what_core::server::{AppState, content_dir_name, create_router};
use what_core::{CollectionPolicyConfig, Config};
pub fn open_collections_config(names: &[&str]) -> Config {
let mut config = Config::default();
for name in names {
config.collections.insert(
name.to_string(),
CollectionPolicyConfig {
owner: Some("none".to_string()),
create: Some("all".to_string()),
update: Some("all".to_string()),
delete: Some("all".to_string()),
read: Some("all".to_string()),
..Default::default()
},
);
}
config
}
pub struct TestProject {
dir: TempDir,
}
impl TestProject {
pub fn new() -> Self {
let dir = TempDir::new().expect("failed to create temp dir");
std::fs::create_dir_all(dir.path().join("site")).unwrap();
Self { dir }
}
pub fn root(&self) -> &Path {
self.dir.path()
}
pub fn add_page(&self, rel_path: &str, content: &str) -> &Self {
let full = self
.dir
.path()
.join(content_dir_name(self.dir.path()))
.join(rel_path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&full, content).unwrap();
self
}
pub fn add_app_config(&self, dir: &str, content: &str) -> &Self {
let target_dir = self
.dir
.path()
.join(content_dir_name(self.dir.path()))
.join(dir);
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::write(target_dir.join("application.what"), content).unwrap();
self
}
pub fn add_component(&self, name: &str, content: &str) -> &Self {
let comp_dir = self.dir.path().join("components");
std::fs::create_dir_all(&comp_dir).unwrap();
std::fs::write(comp_dir.join(name), content).unwrap();
self
}
pub fn add_layout(&self, rel_path: &str, content: &str) -> &Self {
let full = self.dir.path().join(rel_path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&full, content).unwrap();
self
}
pub fn add_static(&self, rel_path: &str, bytes: &[u8]) -> &Self {
let full = self.dir.path().join("static").join(rel_path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&full, bytes).unwrap();
self
}
pub fn add_data_file(&self, rel_path: &str, content: &str) -> &Self {
let full = self.dir.path().join(rel_path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&full, content).unwrap();
self
}
pub fn build_state(self) -> (TempDir, AppState) {
let config = Config::default();
self.build_state_inner(config, false)
}
pub fn build_state_with_config(self, config: Config) -> (TempDir, AppState) {
self.build_state_inner(config, false)
}
pub fn build_state_with_uploads(self) -> (TempDir, AppState) {
let mut config = Config::default();
config.uploads.enabled = true;
config.uploads.directory = "uploads".to_string();
config.uploads.max_size = "1mb".to_string();
self.build_state_inner(config, false)
}
pub fn build_state_dev(self) -> (TempDir, AppState) {
let config = Config::default();
self.build_state_inner(config, true)
}
pub fn build_state_dev_with_config(self, config: Config) -> (TempDir, AppState) {
self.build_state_inner(config, true)
}
fn build_state_inner(self, config: Config, dev: bool) -> (TempDir, AppState) {
let root = self.dir.path().to_path_buf();
let state = AppState::with_dev_mode(config, root, dev).expect("failed to build AppState");
(self.dir, state)
}
}
pub struct TestResponse {
pub status: StatusCode,
pub headers: axum::http::HeaderMap,
pub body: String,
}
impl TestResponse {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|v| v.to_str().ok())
}
pub fn content_type(&self) -> Option<&str> {
self.header("content-type")
}
pub fn set_cookie(&self) -> Option<&str> {
self.header("set-cookie")
}
pub fn session_cookie(&self) -> Option<String> {
let raw = self.set_cookie()?;
raw.split(';').next().map(|s| s.trim().to_string())
}
pub fn location(&self) -> Option<&str> {
self.header("location")
}
pub fn assert_contains(&self, needle: &str) {
assert!(
self.body.contains(needle),
"Expected body to contain {:?}, got:\n{}",
needle,
&self.body[..self.body.len().min(500)]
);
}
pub fn assert_not_contains(&self, needle: &str) {
assert!(
!self.body.contains(needle),
"Expected body NOT to contain {:?}, but it did:\n{}",
needle,
&self.body[..self.body.len().min(500)]
);
}
pub fn csrf_token(&self) -> Option<String> {
let marker = r#"<meta name="csrf-token" content=""#;
let start = self.body.find(marker)?;
let after = &self.body[start + marker.len()..];
let end = after.find('"')?;
Some(after[..end].to_string())
}
}
pub async fn get(router: &Router, path: &str) -> TestResponse {
get_with_headers(router, path, vec![]).await
}
pub async fn get_with_headers(
router: &Router,
path: &str,
extra_headers: Vec<(&str, &str)>,
) -> TestResponse {
let mut builder = Request::builder().method("GET").uri(path);
for (k, v) in extra_headers {
builder = builder.header(k, v);
}
let req = builder.body(Body::empty()).unwrap();
send(router, req).await
}
pub async fn post_form(router: &Router, path: &str, form_data: &str) -> TestResponse {
post_form_with_headers(router, path, form_data, vec![]).await
}
pub async fn post_form_with_headers(
router: &Router,
path: &str,
form_data: &str,
extra_headers: Vec<(&str, &str)>,
) -> TestResponse {
let mut builder = Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded");
for (k, v) in extra_headers {
builder = builder.header(k, v);
}
let req = builder.body(Body::from(form_data.to_string())).unwrap();
send(router, req).await
}
async fn send(router: &Router, req: Request<Body>) -> TestResponse {
let resp = router.clone().oneshot(req).await.expect("request failed");
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp
.into_body()
.collect()
.await
.expect("body read failed")
.to_bytes();
let body = String::from_utf8_lossy(&bytes).into_owned();
TestResponse {
status,
headers,
body,
}
}
pub fn create_test_jwt(claims: &serde_json::Value, secret: &str) -> String {
use jsonwebtoken::{EncodingKey, Header, encode};
encode(
&Header::default(),
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.expect("JWT encoding failed")
}
pub fn build_multipart_body(fields: Vec<(&str, MultipartField)>) -> (String, Vec<u8>) {
let boundary = "----WhatTestBoundary7MA4YWxkTrZu0gW";
let mut body = Vec::new();
for (name, field) in fields {
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
match field {
MultipartField::Text(value) => {
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n",
name, value
)
.as_bytes(),
);
}
MultipartField::File {
filename,
content_type,
data,
} => {
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
name, filename, content_type
).as_bytes()
);
body.extend_from_slice(data);
body.extend_from_slice(b"\r\n");
}
}
}
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
let content_type = format!("multipart/form-data; boundary={}", boundary);
(content_type, body)
}
pub enum MultipartField<'a> {
Text(&'a str),
File {
filename: &'a str,
content_type: &'a str,
data: &'a [u8],
},
}
pub async fn post_multipart(
router: &Router,
path: &str,
fields: Vec<(&str, MultipartField<'_>)>,
) -> TestResponse {
post_multipart_with_headers(router, path, fields, vec![]).await
}
pub async fn post_multipart_with_headers(
router: &Router,
path: &str,
fields: Vec<(&str, MultipartField<'_>)>,
extra_headers: Vec<(&str, &str)>,
) -> TestResponse {
let (content_type, body) = build_multipart_body(fields);
let mut builder = Request::builder()
.method("POST")
.uri(path)
.header("content-type", content_type);
for (k, v) in extra_headers {
builder = builder.header(k, v);
}
let req = builder.body(Body::from(body)).unwrap();
send(router, req).await
}
pub fn auth_config_with_secret(secret: &str) -> Config {
let mut config = Config::default();
config.auth.enabled = true;
config.auth.jwt_secret = Some(secret.to_string());
config.auth.jwt_cookie_name = "w_token".to_string();
config.auth.login_path = "/login".to_string();
config
}