diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39eff1b70..b72e4e7f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,7 @@ jobs: container: mssql db_url: "mssql://root:Password123!@127.0.0.1/sqlpage" - database: oracle + test_args: --test-threads=2 container: oracle db_url: "Driver=Oracle 23 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!" - database: duckdb @@ -142,7 +143,7 @@ jobs: run: docker compose logs ${{ matrix.container }} - name: Run tests against ${{ matrix.database }} timeout-minutes: 5 - run: scripts/run-test-binaries.sh + run: scripts/run-test-binaries.sh ${{ matrix.test_args }} env: DATABASE_URL: ${{ matrix.db_url }} MALLOC_CHECK_: 3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43487637c..252c86e9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +96,14 @@ export DATABASE_URL=mssql://root:Password123!@localhost/sqlpage cargo test ``` +Use `common::response_for(path)` for ordinary integration requests and +`common::response_with(path, config)` for custom configuration. Custom requests use +`common::response_from(request)`; tests sharing database or cache state use `common::send_request`. +These helpers use the production `create_app` routes and middleware through an Actix test service, +whose destructor drains its request pool. +Keep `#[actix_web::test]`; standalone `TestRequest::to_srv_request()` retains application state. +When testing Oracle locally, use `cargo test -- --test-threads=2` to avoid overwhelming the listener. + ### End-to-End Tests We use Playwright for end-to-end testing of dynamic frontend features. diff --git a/scripts/run-test-binaries.sh b/scripts/run-test-binaries.sh index 6a02b2a77..4d0038a4d 100755 --- a/scripts/run-test-binaries.sh +++ b/scripts/run-test-binaries.sh @@ -13,6 +13,6 @@ fi for test_binary in "${test_binaries[@]}"; do echo "::group::$(basename "$test_binary")" - "$test_binary" --quiet + "$test_binary" --quiet "$@" echo "::endgroup::" done diff --git a/src/webserver/database/sqlpage_expr.rs b/src/webserver/database/sqlpage_expr.rs index 126a133fc..7b7ecd769 100644 --- a/src/webserver/database/sqlpage_expr.rs +++ b/src/webserver/database/sqlpage_expr.rs @@ -12,7 +12,6 @@ use serde_json::Value; use super::execute_queries::DbConn; use super::sqlpage_functions::functions::SqlPageFunctionName; use crate::webserver::http_request_info::ExecutionContext; -use crate::webserver::single_or_vec::SingleOrVec; /// An expression evaluated by `SQLPage`. /// @@ -163,50 +162,21 @@ fn value_to_sqlpage_value(value: Value) -> SqlPageValue<'static> { impl VariableRef { fn evaluate<'a>(&self, request: &'a ExecutionContext) -> SqlPageValue<'a> { - let value = match self.source { - VariableSource::Url => request - .url_params - .get(&self.name) - .map(SingleOrVec::as_json_str), - VariableSource::SetOrForm => { - if let Some(value) = request.set_variables.borrow().get(&self.name) { - return value.as_ref().map_or(SqlPageValue::Null, |value| { - SqlPageValue::Text(Cow::Owned(value.as_json_str().into_owned())) - }); - } - request - .post_variables - .get(&self.name) - .map(SingleOrVec::as_json_str) - } - VariableSource::SetOrUrl => { - if let Some(value) = request.set_variables.borrow().get(&self.name) { - return value.as_ref().map_or(SqlPageValue::Null, |value| { - SqlPageValue::Text(Cow::Owned(value.as_json_str().into_owned())) - }); - } - let url_value = request.url_params.get(&self.name); - if request.post_variables.contains_key(&self.name) { - if url_value.is_some() { - log::warn!( - "Deprecation warning! There is both a URL parameter named '{}' and a form field named '{}'. SQLPage is using the URL parameter for ${}. Please use :{} to reference the form field explicitly.", - self.name, - self.name, - self.name, - self.name, - ); - } else { - log::warn!( - "Deprecation warning! ${} was used to reference a form field value (a POST variable). This now uses only URL parameters. Please use :{} instead.", - self.name, - self.name, - ); - } - } - url_value.map(SingleOrVec::as_json_str) - } + use crate::webserver::request_variables::{LookupPolicy, VariableAccess, VariableValue}; + let policy = match self.source { + VariableSource::Url => LookupPolicy::GetOnly, + VariableSource::SetOrForm => LookupPolicy::SetThenPost, + VariableSource::SetOrUrl => LookupPolicy::SetThenGet, }; - value.map_or(SqlPageValue::Null, SqlPageValue::Text) + let variables = VariableAccess::new( + &request.url_params, + &request.post_variables, + &request.set_variables, + ); + match variables.lookup(&self.name, policy) { + VariableValue::Missing | VariableValue::Null => SqlPageValue::Null, + VariableValue::Text(value) => SqlPageValue::Text(value), + } } } diff --git a/src/webserver/database/sqlpage_functions/functions/variables.rs b/src/webserver/database/sqlpage_functions/functions/variables.rs index d482b1534..8ca8aa46e 100644 --- a/src/webserver/database/sqlpage_functions/functions/variables.rs +++ b/src/webserver/database/sqlpage_functions/functions/variables.rs @@ -22,28 +22,11 @@ pub(super) async fn variables<'a>( )); } } else { - use serde::{Serializer, ser::SerializeMap}; - let mut res = Vec::new(); - let mut serializer = serde_json::Serializer::new(&mut res); - let set_vars = request.set_variables.borrow(); - let len = request.url_params.len() + request.post_variables.len() + set_vars.len(); - let mut ser = serializer.serialize_map(Some(len))?; - let mut seen_keys = std::collections::HashSet::new(); - for (k, v) in &*set_vars { - seen_keys.insert(k); - ser.serialize_entry(k, v)?; - } - for (k, v) in &request.post_variables { - if seen_keys.insert(k) { - ser.serialize_entry(k, v)?; - } - } - for (k, v) in &request.url_params { - if seen_keys.insert(k) { - ser.serialize_entry(k, v)?; - } - } - ser.end()?; - String::from_utf8(res)? + let variables = crate::webserver::request_variables::VariableAccess::new( + &request.url_params, + &request.post_variables, + &request.set_variables, + ); + serde_json::to_string(&variables)? }) } diff --git a/src/webserver/request_variables.rs b/src/webserver/request_variables.rs index 1b73f4120..c2ca579c8 100644 --- a/src/webserver/request_variables.rs +++ b/src/webserver/request_variables.rs @@ -1,10 +1,95 @@ +use std::borrow::Cow; +use std::cell::RefCell; use std::collections::{HashMap, hash_map::Entry}; +use serde::{Serialize, Serializer, ser::SerializeMap}; + use crate::webserver::single_or_vec::SingleOrVec; pub type ParamMap = HashMap; pub type SetVariablesMap = HashMap>; +/// Request values stay borrowed; mutable SET values are copied before releasing +/// their `RefCell` borrow. A present SET NULL must suppress request fallbacks. +pub(crate) struct VariableAccess<'a> { + get: &'a ParamMap, + post: &'a ParamMap, + set: &'a RefCell, +} + +#[derive(Clone, Copy)] +pub(crate) enum LookupPolicy { + GetOnly, + SetThenGet, + SetThenPost, +} + +pub(crate) enum VariableValue<'a> { + Missing, + Null, + Text(Cow<'a, str>), +} + +impl<'a> VariableAccess<'a> { + pub(crate) fn new( + get: &'a ParamMap, + post: &'a ParamMap, + set: &'a RefCell, + ) -> Self { + Self { get, post, set } + } + + pub(crate) fn lookup(&self, name: &str, policy: LookupPolicy) -> VariableValue<'a> { + if !matches!(policy, LookupPolicy::GetOnly) + && let Some(value) = self.set.borrow().get(name) + { + return value.as_ref().map_or(VariableValue::Null, |value| { + VariableValue::Text(Cow::Owned(value.as_json_str().into_owned())) + }); + } + let values = match policy { + LookupPolicy::SetThenPost => self.post, + LookupPolicy::GetOnly | LookupPolicy::SetThenGet => self.get, + }; + if matches!(policy, LookupPolicy::SetThenGet) && self.post.contains_key(name) { + if values.contains_key(name) { + log::warn!( + "Deprecation warning! There is both a URL parameter named '{name}' and a form field named '{name}'. SQLPage is using the URL parameter for ${name}. Please use :{name} to reference the form field explicitly." + ); + } else { + log::warn!( + "Deprecation warning! ${name} was used to reference a form field value (a POST variable). This now uses only URL parameters. Please use :{name} instead." + ); + } + } + values.get(name).map_or(VariableValue::Missing, |value| { + VariableValue::Text(value.as_json_str()) + }) + } +} + +/// Serialize the merged SET > POST > GET view without cloning its values. +impl Serialize for VariableAccess<'_> { + fn serialize(&self, serializer: S) -> Result { + let set = self.set.borrow(); + let mut map = serializer.serialize_map(None)?; + for (key, value) in &*set { + map.serialize_entry(key, value)?; + } + for (key, value) in self.post { + if !set.contains_key(key) { + map.serialize_entry(key, value)?; + } + } + for (key, value) in self.get { + if !set.contains_key(key) && !self.post.contains_key(key) { + map.serialize_entry(key, value)?; + } + } + map.end() + } +} + pub fn param_map>(values: PAIRS) -> ParamMap { values .into_iter() @@ -26,3 +111,47 @@ pub fn param_map>(values: PAIRS) -> map }) } + +#[cfg(test)] +mod tests { + use super::*; + use LookupPolicy::{GetOnly, SetThenGet, SetThenPost}; + use VariableValue::{Missing, Null, Text}; + + #[test] + fn lookup_distinguishes_null_missing_and_releases_set_borrows() { + let get = param_map([("value".into(), "get".into())]); + let post = param_map([("value".into(), "post".into())]); + for (policy, fallback) in [(GetOnly, "get"), (SetThenGet, "get"), (SetThenPost, "post")] { + let set = RefCell::new(SetVariablesMap::from([("value".into(), None)])); + let view = VariableAccess::new(&get, &post, &set); + assert!(matches!(view.lookup("absent", policy), Missing)); + let get_only = matches!(policy, GetOnly); + assert_eq!(matches!(view.lookup("value", policy), Null), !get_only); + for (value, expected) in [ + (SingleOrVec::Single("set".into()), "set"), + ( + SingleOrVec::Vec(vec!["a".into(), "b".into()]), + r#"["a","b"]"#, + ), + ] { + set.borrow_mut().insert("value".into(), Some(value)); + let Text(actual) = view.lookup("value", policy) else { + panic!("missing SET") + }; + if expected.starts_with('[') { + assert_eq!( + serde_json::to_value(&view).unwrap()["value"], + serde_json::json!(["a", "b"]) + ); + } + set.borrow_mut().clear(); + assert_eq!(matches!(actual, Cow::Borrowed(_)), get_only); + assert_eq!(actual, if get_only { "get" } else { expected }); + assert!( + matches!(view.lookup("value", policy), Text(Cow::Borrowed(value)) if value == fallback) + ); + } + } + } +} diff --git a/tests/basic/mod.rs b/tests/basic/mod.rs index 1db6cce64..ae1623584 100644 --- a/tests/basic/mod.rs +++ b/tests/basic/mod.rs @@ -4,22 +4,21 @@ use actix_web::{ test, }; -use crate::common::req_path; +use crate::common::{response_for, response_with, test_config}; #[actix_web::test] async fn test_index_ok() { - let resp = req_path("/").await.unwrap(); + let resp = response_for("/").await; assert_eq!(resp.status(), http::StatusCode::OK); - let body = test::read_body(resp).await; - assert!(body.starts_with(b"")); - let body = String::from_utf8(body.to_vec()).unwrap(); + let body = crate::common::read_body_string(resp).await; + assert!(body.starts_with("")); assert!(body.contains("It works !")); assert!(!body.contains("error")); } #[actix_web::test] async fn test_access_config_forbidden() { - let resp_result = req_path("/sqlpage/sqlpage.json").await; + let resp_result = response_with("/sqlpage/sqlpage.json", test_config()).await; assert!( resp_result.is_err(), "Accessing the config file should be forbidden, but we received a response: {resp_result:?}" @@ -35,7 +34,7 @@ async fn test_access_config_forbidden() { #[actix_web::test] async fn test_static_files() { - let resp = req_path("/tests/it_works.txt").await.unwrap(); + let resp = response_for("/tests/it_works.txt").await; assert_eq!(resp.status(), http::StatusCode::OK); let body = test::read_body(resp).await; assert_eq!(&body, &b"It works !"[..]); @@ -43,11 +42,8 @@ async fn test_static_files() { #[actix_web::test] async fn test_spaces_in_file_names() { - let resp = req_path("/tests/core/spaces%20in%20file%20name.sql") - .await - .unwrap(); + let resp = response_for("/tests/core/spaces%20in%20file%20name.sql").await; assert_eq!(resp.status(), http::StatusCode::OK); - let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); + let body_str = crate::common::read_body_string(resp).await; assert!(body_str.contains("It works !"), "{body_str}"); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index da2b689f0..088b99beb 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,10 +3,10 @@ use std::time::Duration; use actix_web::{ App, HttpResponse, HttpServer, - dev::{ServiceRequest, fn_service}, + dev::{ServiceRequest, ServiceResponse, fn_service}, http::header, http::header::ContentType, - test::TestRequest, + test::{self, TestRequest}, web, web::Data, }; @@ -14,101 +14,112 @@ use sqlpage::{ AppState, app_config::{AppConfig, test_database_url}, telemetry, - webserver::http::{form_config, main_handler, payload_config}, + webserver::http::create_app, }; use tokio::sync::oneshot; use tokio::task::JoinHandle; -pub(crate) async fn get_request_to_with_data( - path: &str, - data: Data, -) -> actix_web::Result { - Ok(TestRequest::get() - .uri(path) +/// Builds a GET request with the defaults used by the SQL fixtures. +pub(crate) fn request_for(path: impl AsRef) -> TestRequest { + TestRequest::get() + .uri(path.as_ref()) .insert_header(ContentType::plaintext()) .insert_header(header::Accept::html()) - .app_data(payload_config(&data)) - .app_data(form_config(&data)) - .app_data(data)) } -pub(crate) async fn get_request_to(path: &str) -> actix_web::Result { - let data = make_app_data().await; - get_request_to_with_data(path, data).await +pub(crate) fn multipart_request(path: &str, body: impl Into) -> TestRequest { + request_for(path) + .insert_header(("content-type", "multipart/form-data; boundary=1234567890")) + .set_payload(body) } -pub(crate) async fn make_app_data_from_config(config: AppConfig) -> Data { - let state = AppState::init(&config).await.unwrap(); - Data::new(state) +pub(crate) async fn make_app_data_from_config( + config: AppConfig, +) -> actix_web::Result> { + init_log(); + AppState::init(&config) + .await + .map(Data::new) + .map_err(actix_web::error::ErrorInternalServerError) } -pub(crate) async fn make_app_data() -> Data { - init_log(); - let config = test_config(); - make_app_data_from_config(config).await +/// Sends a GET request with the default test configuration, failing the test on error. +pub(crate) async fn response_for(path: impl AsRef) -> ServiceResponse { + response_with(path.as_ref(), test_config()) + .await + .unwrap_or_else(|err| panic!("Request to {} failed: {err:#}", path.as_ref())) } -pub(crate) async fn req_path( +/// Sends a GET request with custom application configuration. +pub(crate) async fn response_with( path: impl AsRef, -) -> Result { - let req = get_request_to(path.as_ref()).await?.to_srv_request(); - main_handler(req).await + config: AppConfig, +) -> actix_web::Result { + send_request(request_for(path), make_app_data_from_config(config).await?).await } -const REQ_TIMEOUT: Duration = Duration::from_secs(8); -pub(crate) async fn req_path_with_app_data( - path: impl AsRef, - app_data: Data, -) -> anyhow::Result { - req_path_with_app_data_and_accept(path, app_data, header::Accept::html()).await +/// Sends a custom request with the default test configuration. +pub(crate) async fn response_from(request: TestRequest) -> actix_web::Result { + send_request(request, make_app_data_from_config(test_config()).await?).await } -pub(crate) async fn req_path_with_app_data_json( +/// Sends a GET request using existing state, for tests that share a database or cache. +pub(crate) async fn response_with_data( path: impl AsRef, - app_data: Data, -) -> anyhow::Result { - req_path_with_app_data_and_accept(path, app_data, header::Accept::json()).await + data: Data, +) -> actix_web::Result { + send_request(request_for(path), data).await } -async fn req_path_with_app_data_and_accept( - path: impl AsRef, - app_data: Data, - accept: header::Accept, -) -> anyhow::Result { - let path = path.as_ref(); - let req = TestRequest::get() - .uri(path) - .app_data(app_data) - .insert_header(("cookie", "test_cook=123")) - .insert_header(("authorization", "Basic dGVzdDp0ZXN0")) - .insert_header(accept) - .to_srv_request(); - let resp = tokio::time::timeout(REQ_TIMEOUT, main_handler(req)) - .await - .map_err(|e| anyhow::anyhow!("Request to {path} timed out: {e}"))? - .map_err(|e| { - anyhow::anyhow!( - "Request to {path} failed with status {}: {e:#}", - e.as_response_error().status_code() - ) - })?; - Ok(resp) +/// Runs a request through the production app, including its routes and middleware. +/// Dropping the service disables and drains the pool, including on error or panic. +/// Standalone `TestRequest::to_srv_request()` would instead leak the application state. +pub(crate) async fn send_request( + request: TestRequest, + data: Data, +) -> actix_web::Result { + let app = test::init_service(create_app(data)).await; + tokio::time::timeout( + Duration::from_secs(8), + test::try_call_service(&app, request.to_request()), + ) + .await + .map_err(actix_web::error::ErrorGatewayTimeout)? + .map(ServiceResponse::map_into_boxed_body) +} + +/// Reads a whole response body as a UTF-8 string, failing the test otherwise. +pub(crate) async fn read_body_string(resp: ServiceResponse) -> String +where + B: actix_web::body::MessageBody, +{ + String::from_utf8(test::read_body(resp).await.to_vec()).unwrap() +} + +/// Whether the database engine is one of `kinds`. +/// Tests that only run on some engines return early otherwise. +pub(crate) fn supports_database( + db: &sqlpage::webserver::database::Database, + kinds: &[sqlpage::webserver::database::SupportedDatabase], +) -> bool { + kinds.contains(&db.info.database_type) +} + +pub(crate) async fn make_app_data() -> Data { + make_app_data_from_config(test_config()).await.unwrap() } pub(crate) fn test_config() -> AppConfig { - let db_url = test_database_url(); - serde_json::from_str::(&format!( - r#"{{ - "database_url": "{db_url}", + serde_json::from_value(serde_json::json!({ + "database_url": test_database_url(), "max_database_pool_connections": 1, "database_connection_retries": 3, "database_connection_acquire_timeout_seconds": 15, "allow_exec": true, - "max_uploaded_file_size": 123456, + "max_uploaded_file_size": 123_456, "listen_on": "111.111.111.111:1", - "system_root_ca_certificates" : false - }}"# - )) + "system_root_ca_certificates": false + })) .unwrap() } diff --git a/tests/cookies/mod.rs b/tests/cookies/mod.rs index 1fa2b1db8..ed254d056 100644 --- a/tests/cookies/mod.rs +++ b/tests/cookies/mod.rs @@ -1,9 +1,9 @@ -use crate::common::req_path; +use crate::common::response_for; use actix_web::http::StatusCode; use actix_web::http::header::SET_COOKIE; async fn set_cookie_header(path: &str) -> String { - let resp = req_path(path).await.unwrap(); + let resp = response_for(path).await; resp.headers() .get(SET_COOKIE) .unwrap_or_else(|| panic!("{path} should have sent a Set-Cookie header")) @@ -33,9 +33,7 @@ async fn zero_turns_off_a_cookie_protection() { #[actix_web::test] async fn a_page_made_only_of_header_components_still_sends_them() { - let resp = req_path("/tests/cookies/header_only_response.sql") - .await - .unwrap(); + let resp = response_for("/tests/cookies/header_only_response.sql").await; assert_eq!(resp.status(), StatusCode::NO_CONTENT); assert!(resp.headers().contains_key(SET_COOKIE)); } diff --git a/tests/core/mod.rs b/tests/core/mod.rs index 6062d877c..592360646 100644 --- a/tests/core/mod.rs +++ b/tests/core/mod.rs @@ -1,24 +1,61 @@ -use actix_web::{http::StatusCode, test}; +use actix_web::http::StatusCode; use sqlpage::{ AppState, webserver::{self, make_placeholder}, }; -use sqlx::executor::Executor as _; -use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config}; +use crate::common::{ + make_app_data_from_config, response_for, response_with, response_with_data, test_config, +}; mod path_aliases; +/// Creates the `sqlpage_files` table if needed and stores `contents` at `path`. +/// Other tests share this database, so the table is never dropped. +async fn store_file_in_db(state: &AppState, path: &str, contents: &[u8]) { + use sqlx::executor::Executor as _; + + let create_table_sql = + sqlpage::filesystem::DbFsQueries::get_create_table_sql(state.db.info.database_type); + if state + .db + .connection + .execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0") + .await + .is_err() + { + state.db.connection.execute(create_table_sql).await.unwrap(); + } + let delete_sql = format!("DELETE FROM sqlpage_files WHERE path = '{path}'"); + state + .db + .connection + .execute(delete_sql.as_str()) + .await + .unwrap(); + let insert_sql = format!( + "INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})", + make_placeholder(state.db.info.kind, 1), + make_placeholder(state.db.info.kind, 2) + ); + sqlx::query::query(&insert_sql) + .bind(path) + .bind(contents) + .execute(&state.db.connection) + .await + .unwrap(); +} + #[actix_web::test] async fn test_concurrent_requests() { let components = [ "table", "form", "card", "datagrid", "hero", "list", "timeline", ]; - let app_data = make_app_data_from_config(test_config()).await; + let app_data = crate::common::make_app_data().await; let reqs = (0..64) .map(|i| { let component = components[i % components.len()]; - req_path_with_app_data( + response_with_data( format!("/tests/components/any_component.sql?component={component}"), app_data.clone(), ) @@ -28,12 +65,8 @@ async fn test_concurrent_requests() { for result in results { let resp = result.unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = test::read_body(resp).await; - assert!( - body.starts_with(b""), - "Expected html doctype" - ); - let body = String::from_utf8(body.to_vec()).unwrap(); + let body = crate::common::read_body_string(resp).await; + assert!(body.starts_with(""), "Expected html doctype"); assert!( body.contains("It works !"), "Expected to contain: It works !, but got: {body}" @@ -44,11 +77,9 @@ async fn test_concurrent_requests() { #[actix_web::test] async fn test_datagrid_description_presence_controls_placeholder() { - let resp = req_path("/tests/components/datagrid_icon_only.sql") - .await - .unwrap(); + let resp = response_for("/tests/components/datagrid_icon_only.sql").await; assert_eq!(resp.status(), StatusCode::OK); - let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); + let body = crate::common::read_body_string(resp).await; assert!(body.contains("Facebook"), "{body}"); assert!(body.contains("Empty"), "{body}"); assert!(body.contains("Missing"), "{body}"); @@ -66,50 +97,23 @@ async fn test_routing_with_db_fs() { config.site_prefix = "/prefix/".to_string(); let state = AppState::init(&config).await.unwrap(); - if matches!( - state.db.info.database_type, - webserver::database::SupportedDatabase::Oracle + if crate::common::supports_database( + &state.db, + &[webserver::database::SupportedDatabase::Oracle], ) { return; } - let create_table_sql = - sqlpage::filesystem::DbFsQueries::get_create_table_sql(state.db.info.database_type); - // Other tests share this database, so never drop a table their initialized state may use. - if state - .db - .connection - .execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0") - .await - .is_err() - { - state.db.connection.execute(create_table_sql).await.unwrap(); - } - state - .db - .connection - .execute("DELETE FROM sqlpage_files WHERE path = 'on_db.sql'") - .await - .unwrap(); - let insert_sql = format!( - "INSERT INTO sqlpage_files(path, contents) VALUES ('on_db.sql', {})", - make_placeholder(state.db.info.kind, 1) - ); - sqlx::query::query(&insert_sql) - .bind("select ''text'' as component, ''Hi from db !'' AS contents;".as_bytes()) - .execute(&state.db.connection) - .await - .unwrap(); - - let state = AppState::init(&config).await.unwrap(); - let app_data = actix_web::web::Data::new(state); + store_file_in_db( + &state, + "on_db.sql", + b"select ''text'' as component, ''Hi from db !'' AS contents;", + ) + .await; - let resp = req_path_with_app_data("/prefix/on_db.sql", app_data.clone()) - .await - .unwrap(); + let resp = response_with("/prefix/on_db.sql", config).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); + let body_str = crate::common::read_body_string(resp).await; assert!( body_str.contains("Hi from db !"), "{body_str}\nexpected to contain: Hi from db !" @@ -128,35 +132,9 @@ async fn test_non_unicode_static_path_returns_bad_request_with_db_fs() { let state = AppState::init(&config).await.unwrap(); let expected_db_path = "\u{FFFD}.txt"; - let mut conn = state.db.connection.acquire().await.unwrap(); + store_file_in_db(&state, expected_db_path, b"file from db fs").await; - (&mut *conn) - .execute(sqlpage::filesystem::DbFsQueries::get_create_table_sql( - webserver::database::SupportedDatabase::Sqlite, - )) - .await - .unwrap(); - let insert_sql = format!( - "INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})", - make_placeholder(state.db.info.kind, 1), - make_placeholder(state.db.info.kind, 2) - ); - sqlx::query::query(&insert_sql) - .bind(expected_db_path) - .bind("file from db fs".as_bytes()) - .execute(&mut *conn) - .await - .unwrap(); - drop(conn); - - let state = AppState::init(&config).await.unwrap(); - let app_data = actix_web::web::Data::new(state); - let req = test::TestRequest::get() - .uri("/%FF.txt") - .app_data(app_data) - .to_srv_request(); - - let err = webserver::http::main_handler(req) + let err = response_with("/%FF.txt", config) .await .expect_err("non-unicode path should not panic and must return bad request"); assert_eq!( @@ -169,18 +147,13 @@ async fn test_non_unicode_static_path_returns_bad_request_with_db_fs() { async fn test_routing_with_prefix() { let mut config = test_config(); config.site_prefix = "/prefix/".to_string(); - let state = AppState::init(&config).await.unwrap(); - - let app_data = actix_web::web::Data::new(state); - let resp = req_path_with_app_data( - "/prefix/tests/sql_test_files/component_rendering/simple.sql", - app_data.clone(), - ) - .await - .unwrap(); + let app_data = make_app_data_from_config(config).await.unwrap(); + let path = "/tests/sql_test_files/component_rendering/simple.sql"; + let resp = response_with_data(format!("/prefix{path}"), app_data.clone()) + .await + .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); + let body_str = crate::common::read_body_string(resp).await; assert!( body_str.contains("It works !"), "{body_str}\nexpected to contain: It works !" @@ -190,47 +163,41 @@ async fn test_routing_with_prefix() { "{body_str}\nexpected to contain links with site prefix" ); - let resp = req_path_with_app_data("/prefix/nonexistent.sql", app_data.clone()) + let resp = response_with_data("/prefix/nonexistent.sql", app_data.clone()) .await .expect("should handle 404"); - let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); + let body_str = crate::common::read_body_string(resp).await; assert!( body_str.contains("404"), "Response should contain \"404\", but got:\n{body_str}" ); - let resp = req_path_with_app_data("/prefix/sqlpage/migrations/0001_init.sql", app_data.clone()) + let resp = response_with_data("/prefix/sqlpage/migrations/0001_init.sql", app_data.clone()) .await .expect_err("Expected forbidden error") - .to_string(); - assert!(resp.to_lowercase().contains("forbidden"), "{resp}"); + .as_response_error() + .status_code(); + assert_eq!(resp, StatusCode::FORBIDDEN); - let resp = req_path_with_app_data( - "/tests/sql_test_files/component_rendering/simple.sql", - app_data, - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY); + let resp = response_with_data(path, app_data).await.unwrap(); + assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT); let location = resp .headers() .get("location") .expect("location header should be present"); - assert_eq!(location.to_str().unwrap(), "/prefix/"); + assert_eq!(location.to_str().unwrap(), format!("/prefix{path}")); } #[actix_web::test] async fn test_hidden_files() { - let resp_result = req_path("/tests/core/.hidden.sql").await; + let resp_result = response_with("/tests/core/.hidden.sql", test_config()).await; assert!( resp_result.is_err(), "Accessing a hidden file should be forbidden, but received success: {resp_result:?}" ); let resp = resp_result.unwrap_err().error_response(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); - let srv_resp = test::TestRequest::default().to_srv_response(resp); - let body = test::read_body(srv_resp).await; + let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap(); assert!( String::from_utf8_lossy(&body) .to_lowercase() @@ -241,14 +208,13 @@ async fn test_hidden_files() { #[actix_web::test] async fn test_official_website_documentation() { let app_data = make_app_data_for_official_website().await; - let resp = req_path_with_app_data("/component.sql?component=button", app_data) + let resp = response_with_data("/component.sql?component=button", app_data) .await .unwrap_or_else(|e| { panic!("Failed to get response for /component.sql?component=button: {e}") }); assert_eq!(resp.status(), StatusCode::OK); - let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); + let body_str = crate::common::read_body_string(resp).await; assert!( body_str.contains(r#"