From 03de5fd866de479d75d1b49f97df1b7a0b21b17e Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Sun, 31 May 2026 22:59:00 +0200 Subject: [PATCH 1/4] Add example application smoke tests --- examples/mysql json handling/index.sql | 12 +- .../migrations/0001_users_and_groups.sql | 6 +- tests/examples/mod.rs | 418 ++++++++++++++++++ tests/mod.rs | 1 + 4 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 tests/examples/mod.rs diff --git a/examples/mysql json handling/index.sql b/examples/mysql json handling/index.sql index 4d155872e..3b14bf218 100644 --- a/examples/mysql json handling/index.sql +++ b/examples/mysql json handling/index.sql @@ -1,10 +1,10 @@ select 'form' as component, 'Create a new Group' as title, 'Create' as validate; select 'Name' as name; -insert into groups(name) select :Name where :Name is not null; +insert into `groups`(name) select :Name where :Name is not null; select 'list' as component, 'Groups' as title, 'No group yet' as empty_title; -select name as title from groups; +select name as title from `groups`; select 'form' as component, 'Add a user' as title, 'Add' as validate; select 'UserName' as name, 'Name' as label; @@ -15,7 +15,7 @@ select TRUE as multiple, 'press ctrl to select multiple values' as description, json_arrayagg(json_object("label", name, "value", id)) as options -from groups; +from `groups`; insert into users(name) select :UserName where :UserName is not null; insert into group_members(group_id, user_id) @@ -28,8 +28,8 @@ where :Memberships is not null; select 'list' as component, 'Users' as title, 'No user yet' as empty_title; select users.name as title, - group_concat(groups.name) as description + group_concat(`groups`.name) as description from users left join group_members on users.id = group_members.user_id -left join groups on groups.id = group_members.group_id -group by users.id, users.name; \ No newline at end of file +left join `groups` on `groups`.id = group_members.group_id +group by users.id, users.name; diff --git a/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql b/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql index 954872c01..5c0def645 100644 --- a/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql +++ b/examples/mysql json handling/sqlpage/migrations/0001_users_and_groups.sql @@ -3,7 +3,7 @@ create table users ( name varchar(255) not null ); -create table groups ( +create table `groups` ( id int primary key auto_increment, name varchar(255) not null ); @@ -12,6 +12,6 @@ create table group_members ( group_id int not null, user_id int not null, primary key (group_id, user_id), - foreign key (group_id) references groups (id), + foreign key (group_id) references `groups` (id), foreign key (user_id) references users (id) -); \ No newline at end of file +); diff --git a/tests/examples/mod.rs b/tests/examples/mod.rs new file mode 100644 index 000000000..1aaeea46b --- /dev/null +++ b/tests/examples/mod.rs @@ -0,0 +1,418 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::Duration; + +use actix_web::{http::StatusCode, test as actix_test, web::Data}; +use sqlpage::{AppState, webserver::http::main_handler}; +use sqlx::{Connection, Executor}; + +#[derive(Clone, Copy)] +enum Backend { + Sqlite, + Postgres, + MySql, +} + +#[derive(Clone, Copy)] +struct ExampleApp { + name: &'static str, + web_root: &'static str, + config_dir: &'static str, + route: &'static str, + backend: Backend, +} + +const SKIPPED_EXAMPLES: &[(&str, &str)] = &[ + ( + "PostGIS - using sqlpage with geographic data", + "requires a PostGIS-enabled PostgreSQL database", + ), + ( + "make a geographic data application using sqlite extensions", + "requires the SpatiaLite SQLite extension", + ), + ( + "microsoft sql server advanced forms", + "requires a SQL Server service", + ), + ( + "single sign on", + "requires a Keycloak/OpenID Connect service", + ), +]; + +#[actix_web::test] +async fn example_applications_render_their_entrypoint() { + crate::common::init_log(); + let postgres_base = prepare_postgres_base_url().await; + let mysql_base = prepare_mysql_base_url().await; + + for example in example_apps() { + let database_url = match example.backend { + Backend::Sqlite => "sqlite::memory:".to_string(), + Backend::Postgres => match &postgres_base { + Some(base) => format!("{base}_{}", slug(example.name)), + None => { + eprintln!("skipping {}: PostgreSQL is not available", example.name); + continue; + } + }, + Backend::MySql => match &mysql_base { + Some(base) => format!("{base}_{}", slug(example.name)), + None => { + eprintln!("skipping {}: MySQL is not available", example.name); + continue; + } + }, + }; + prepare_database(&database_url, example.backend).await; + smoke_example(example, &database_url).await; + } +} + +#[test] +fn all_example_applications_are_smoked_or_explicitly_skipped() { + let tested = example_apps() + .into_iter() + .map(|example| example.name) + .collect::>(); + let skipped = SKIPPED_EXAMPLES + .iter() + .map(|(name, _reason)| *name) + .collect::>(); + let mut missing = Vec::new(); + for entry in std::fs::read_dir("examples").unwrap() { + let entry = entry.unwrap(); + if !entry.file_type().unwrap().is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if !tested.contains(name.as_str()) && !skipped.contains(name.as_str()) { + missing.push(name); + } + } + missing.sort(); + assert!( + missing.is_empty(), + "examples missing from smoke coverage or explicit skip list: {missing:?}" + ); +} + +fn example_apps() -> [ExampleApp; 31] { + [ + ExampleApp { + name: "CRUD - Authentication", + web_root: "examples/CRUD - Authentication/www", + config_dir: "examples/CRUD - Authentication/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "cards-with-remote-content", + web_root: "examples/cards-with-remote-content", + config_dir: "examples/cards-with-remote-content/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "charts, computations and custom components", + web_root: "examples/charts, computations and custom components", + config_dir: "examples/charts, computations and custom components/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "corporate-conundrum", + web_root: "examples/corporate-conundrum", + config_dir: "examples/corporate-conundrum/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "forms with a variable number of fields", + web_root: "examples/forms with a variable number of fields", + config_dir: "examples/forms with a variable number of fields/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "forms-with-multiple-steps", + web_root: "examples/forms-with-multiple-steps", + config_dir: "examples/forms-with-multiple-steps/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "handle-404", + web_root: "examples/handle-404", + config_dir: "examples/handle-404/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "image gallery with user uploads", + web_root: "examples/image gallery with user uploads", + config_dir: "examples/image gallery with user uploads/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "light-dark-toggle", + web_root: "examples/light-dark-toggle", + config_dir: "examples/light-dark-toggle/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "master-detail-forms", + web_root: "examples/master-detail-forms", + config_dir: "examples/master-detail-forms/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "modeling a many to many relationship with a form", + web_root: "examples/modeling a many to many relationship with a form", + config_dir: "examples/modeling a many to many relationship with a form/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "multiple-choice-question", + web_root: "examples/multiple-choice-question", + config_dir: "examples/multiple-choice-question/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "official-site", + web_root: "examples/official-site", + config_dir: "examples/official-site/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "plots tables and forms", + web_root: "examples/plots tables and forms", + config_dir: "examples/plots tables and forms/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "read-and-set-http-cookies", + web_root: "examples/read-and-set-http-cookies", + config_dir: "examples/read-and-set-http-cookies/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "rich-text-editor", + web_root: "examples/rich-text-editor", + config_dir: "examples/rich-text-editor/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "roundest_pokemon_rating", + web_root: "examples/roundest_pokemon_rating/src", + config_dir: "examples/roundest_pokemon_rating/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "sending emails", + web_root: "examples/sending emails", + config_dir: "examples/sending emails/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "simple-website-example", + web_root: "examples/simple-website-example", + config_dir: "examples/simple-website-example/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "splitwise", + web_root: "examples/splitwise", + config_dir: "examples/splitwise/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "todo application", + web_root: "examples/todo application", + config_dir: "examples/todo application/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "user-authentication", + web_root: "examples/user-authentication", + config_dir: "examples/user-authentication/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "using react and other custom scripts and styles", + web_root: "examples/using react and other custom scripts and styles", + config_dir: "examples/using react and other custom scripts and styles/sqlpage", + route: "/", + backend: Backend::Sqlite, + }, + ExampleApp { + name: "web servers - apache", + web_root: "examples/web servers - apache/website", + config_dir: "examples/web servers - apache/sqlpage_config", + route: "/my_website/", + backend: Backend::MySql, + }, + ExampleApp { + name: "custom form component", + web_root: "examples/custom form component", + config_dir: "examples/custom form component/sqlpage", + route: "/", + backend: Backend::MySql, + }, + ExampleApp { + name: "mysql json handling", + web_root: "examples/mysql json handling", + config_dir: "examples/mysql json handling/sqlpage", + route: "/", + backend: Backend::MySql, + }, + ExampleApp { + name: "nginx", + web_root: "examples/nginx/website", + config_dir: "examples/nginx/sqlpage_config", + route: "/", + backend: Backend::MySql, + }, + ExampleApp { + name: "SQLPage developer user interface", + web_root: "examples/SQLPage developer user interface/website", + config_dir: "examples/SQLPage developer user interface/sqlpage", + route: "/", + backend: Backend::Postgres, + }, + ExampleApp { + name: "telemetry", + web_root: "examples/telemetry/website", + config_dir: "examples/telemetry/sqlpage", + route: "/", + backend: Backend::Postgres, + }, + ExampleApp { + name: "tiny_twitter", + web_root: "examples/tiny_twitter", + config_dir: "examples/tiny_twitter/sqlpage", + route: "/", + backend: Backend::Postgres, + }, + ExampleApp { + name: "todo application (PostgreSQL)", + web_root: "examples/todo application (PostgreSQL)", + config_dir: "examples/todo application (PostgreSQL)/sqlpage", + route: "/", + backend: Backend::Postgres, + }, + ] +} + +async fn smoke_example(example: ExampleApp, database_url: &str) { + let mut config = crate::common::test_config(); + config.web_root = PathBuf::from(example.web_root); + config.configuration_directory = PathBuf::from(example.config_dir); + config.database_url = database_url.to_string(); + config.max_database_pool_connections = Some(1); + config.database_connection_retries = 0; + config.sqlite_extensions.clear(); + if example.route.starts_with("/my_website/") { + config.site_prefix = "/my_website/".to_string(); + } + + let state = AppState::init(&config) + .await + .unwrap_or_else(|err| panic!("{} failed to initialize: {err:#}", example.name)); + sqlpage::webserver::database::migrations::apply(&config, &state.db) + .await + .unwrap_or_else(|err| panic!("{} migrations failed: {err:#}", example.name)); + let data = Data::new(state); + let req = actix_test::TestRequest::get() + .uri(example.route) + .app_data(data) + .to_srv_request(); + let response = main_handler(req) + .await + .unwrap_or_else(|err| panic!("{} request failed: {err:#}", example.name)); + let status = response.status(); + let body = String::from_utf8_lossy(&actix_test::read_body(response).await).into_owned(); + + assert!( + status < StatusCode::INTERNAL_SERVER_ERROR, + "{} returned {status}: {body}", + example.name + ); + assert!( + !body.contains("sqlpage-error-description"), + "{} rendered a SQLPage error: {body}", + example.name + ); +} + +async fn prepare_postgres_base_url() -> Option { + let admin_url = "postgres://root:Password123!@localhost/postgres"; + let mut conn = sqlx::PgConnection::connect(admin_url).await.ok()?; + conn.execute("SELECT 1").await.ok()?; + Some("postgres://root:Password123!@localhost/sqlpage_examples".to_string()) +} + +async fn prepare_mysql_base_url() -> Option { + let mut conn = sqlx::MySqlConnection::connect("mysql://root:Password123!@localhost/mysql") + .await + .ok()?; + conn.execute("SELECT 1").await.ok()?; + Some("mysql://root:Password123!@localhost/sqlpage_examples".to_string()) +} + +async fn prepare_database(database_url: &str, backend: Backend) { + match backend { + Backend::Sqlite => {} + Backend::Postgres => { + let db_name = database_url.rsplit('/').next().unwrap(); + let mut conn = + sqlx::PgConnection::connect("postgres://root:Password123!@localhost/postgres") + .await + .unwrap(); + let _ = conn + .execute(format!("DROP DATABASE IF EXISTS {db_name} WITH (FORCE)").as_str()) + .await; + conn.execute(format!("CREATE DATABASE {db_name}").as_str()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + Backend::MySql => { + let db_name = database_url.rsplit('/').next().unwrap(); + let mut conn = + sqlx::MySqlConnection::connect("mysql://root:Password123!@localhost/mysql") + .await + .unwrap(); + conn.execute(format!("DROP DATABASE IF EXISTS {db_name}").as_str()) + .await + .unwrap(); + conn.execute(format!("CREATE DATABASE {db_name}").as_str()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } +} + +fn slug(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} diff --git a/tests/mod.rs b/tests/mod.rs index 09ad6b047..c8e620101 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -3,6 +3,7 @@ mod common; mod core; mod data_formats; mod errors; +mod examples; mod oidc; mod requests; mod server_timing; From 8a5b47cd9740c3a08b8a4b5328491b59fe015c53 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Mon, 1 Jun 2026 11:00:10 +0200 Subject: [PATCH 2/4] Discover example smoke tests from folders --- .../CRUD - Authentication/sqlpage_test.json | 3 + .../sqlpage_test.json | 3 + .../sqlpage_test.json | 4 + .../custom form component/sqlpage_test.json | 3 + .../sqlpage_test.json | 3 + .../sqlpage_test.json | 3 + .../mysql json handling/sqlpage_test.json | 3 + examples/nginx/sqlpage_test.json | 5 + .../roundest_pokemon_rating/sqlpage_test.json | 3 + examples/single sign on/sqlpage_test.json | 3 + examples/telemetry/sqlpage_test.json | 4 + examples/tiny_twitter/sqlpage_test.json | 3 + .../sqlpage_test.json | 3 + .../web servers - apache/sqlpage_test.json | 7 + tests/examples/mod.rs | 361 +++++------------- 15 files changed, 138 insertions(+), 273 deletions(-) create mode 100644 examples/CRUD - Authentication/sqlpage_test.json create mode 100644 examples/PostGIS - using sqlpage with geographic data/sqlpage_test.json create mode 100644 examples/SQLPage developer user interface/sqlpage_test.json create mode 100644 examples/custom form component/sqlpage_test.json create mode 100644 examples/make a geographic data application using sqlite extensions/sqlpage_test.json create mode 100644 examples/microsoft sql server advanced forms/sqlpage_test.json create mode 100644 examples/mysql json handling/sqlpage_test.json create mode 100644 examples/nginx/sqlpage_test.json create mode 100644 examples/roundest_pokemon_rating/sqlpage_test.json create mode 100644 examples/single sign on/sqlpage_test.json create mode 100644 examples/telemetry/sqlpage_test.json create mode 100644 examples/tiny_twitter/sqlpage_test.json create mode 100644 examples/todo application (PostgreSQL)/sqlpage_test.json create mode 100644 examples/web servers - apache/sqlpage_test.json diff --git a/examples/CRUD - Authentication/sqlpage_test.json b/examples/CRUD - Authentication/sqlpage_test.json new file mode 100644 index 000000000..09b36bb2a --- /dev/null +++ b/examples/CRUD - Authentication/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "web_root": "www" +} diff --git a/examples/PostGIS - using sqlpage with geographic data/sqlpage_test.json b/examples/PostGIS - using sqlpage with geographic data/sqlpage_test.json new file mode 100644 index 000000000..e7181e6cd --- /dev/null +++ b/examples/PostGIS - using sqlpage with geographic data/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "skip": "requires a PostGIS-enabled PostgreSQL database" +} diff --git a/examples/SQLPage developer user interface/sqlpage_test.json b/examples/SQLPage developer user interface/sqlpage_test.json new file mode 100644 index 000000000..25b11b387 --- /dev/null +++ b/examples/SQLPage developer user interface/sqlpage_test.json @@ -0,0 +1,4 @@ +{ + "backend": "postgres", + "web_root": "website" +} diff --git a/examples/custom form component/sqlpage_test.json b/examples/custom form component/sqlpage_test.json new file mode 100644 index 000000000..f558ce692 --- /dev/null +++ b/examples/custom form component/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "backend": "mysql" +} diff --git a/examples/make a geographic data application using sqlite extensions/sqlpage_test.json b/examples/make a geographic data application using sqlite extensions/sqlpage_test.json new file mode 100644 index 000000000..fdefb4804 --- /dev/null +++ b/examples/make a geographic data application using sqlite extensions/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "skip": "requires the SpatiaLite SQLite extension" +} diff --git a/examples/microsoft sql server advanced forms/sqlpage_test.json b/examples/microsoft sql server advanced forms/sqlpage_test.json new file mode 100644 index 000000000..33eb499ad --- /dev/null +++ b/examples/microsoft sql server advanced forms/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "skip": "requires a SQL Server service" +} diff --git a/examples/mysql json handling/sqlpage_test.json b/examples/mysql json handling/sqlpage_test.json new file mode 100644 index 000000000..f558ce692 --- /dev/null +++ b/examples/mysql json handling/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "backend": "mysql" +} diff --git a/examples/nginx/sqlpage_test.json b/examples/nginx/sqlpage_test.json new file mode 100644 index 000000000..f02ac3468 --- /dev/null +++ b/examples/nginx/sqlpage_test.json @@ -0,0 +1,5 @@ +{ + "backend": "mysql", + "config_dir": "sqlpage_config", + "web_root": "website" +} diff --git a/examples/roundest_pokemon_rating/sqlpage_test.json b/examples/roundest_pokemon_rating/sqlpage_test.json new file mode 100644 index 000000000..badb7b92f --- /dev/null +++ b/examples/roundest_pokemon_rating/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "web_root": "src" +} diff --git a/examples/single sign on/sqlpage_test.json b/examples/single sign on/sqlpage_test.json new file mode 100644 index 000000000..49f3d0a82 --- /dev/null +++ b/examples/single sign on/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "skip": "requires a Keycloak/OpenID Connect service" +} diff --git a/examples/telemetry/sqlpage_test.json b/examples/telemetry/sqlpage_test.json new file mode 100644 index 000000000..25b11b387 --- /dev/null +++ b/examples/telemetry/sqlpage_test.json @@ -0,0 +1,4 @@ +{ + "backend": "postgres", + "web_root": "website" +} diff --git a/examples/tiny_twitter/sqlpage_test.json b/examples/tiny_twitter/sqlpage_test.json new file mode 100644 index 000000000..ec1435636 --- /dev/null +++ b/examples/tiny_twitter/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "backend": "postgres" +} diff --git a/examples/todo application (PostgreSQL)/sqlpage_test.json b/examples/todo application (PostgreSQL)/sqlpage_test.json new file mode 100644 index 000000000..ec1435636 --- /dev/null +++ b/examples/todo application (PostgreSQL)/sqlpage_test.json @@ -0,0 +1,3 @@ +{ + "backend": "postgres" +} diff --git a/examples/web servers - apache/sqlpage_test.json b/examples/web servers - apache/sqlpage_test.json new file mode 100644 index 000000000..58b24e7d6 --- /dev/null +++ b/examples/web servers - apache/sqlpage_test.json @@ -0,0 +1,7 @@ +{ + "backend": "mysql", + "config_dir": "sqlpage_config", + "route": "/my_website/", + "site_prefix": "/my_website/", + "web_root": "website" +} diff --git a/tests/examples/mod.rs b/tests/examples/mod.rs index 1aaeea46b..e7a78ff49 100644 --- a/tests/examples/mod.rs +++ b/tests/examples/mod.rs @@ -1,45 +1,43 @@ use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use actix_web::{http::StatusCode, test as actix_test, web::Data}; +use serde::Deserialize; use sqlpage::{AppState, webserver::http::main_handler}; use sqlx::{Connection, Executor}; -#[derive(Clone, Copy)] +const EXAMPLES_DIR: &str = "examples"; +const TEST_CONFIG_FILE: &str = "sqlpage_test.json"; + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] enum Backend { Sqlite, Postgres, MySql, } -#[derive(Clone, Copy)] struct ExampleApp { - name: &'static str, - web_root: &'static str, - config_dir: &'static str, - route: &'static str, + name: String, + web_root: PathBuf, + config_dir: PathBuf, + route: String, + site_prefix: Option, backend: Backend, + skip: Option, } -const SKIPPED_EXAMPLES: &[(&str, &str)] = &[ - ( - "PostGIS - using sqlpage with geographic data", - "requires a PostGIS-enabled PostgreSQL database", - ), - ( - "make a geographic data application using sqlite extensions", - "requires the SpatiaLite SQLite extension", - ), - ( - "microsoft sql server advanced forms", - "requires a SQL Server service", - ), - ( - "single sign on", - "requires a Keycloak/OpenID Connect service", - ), -]; +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExampleTestConfig { + backend: Option, + web_root: Option, + config_dir: Option, + route: Option, + site_prefix: Option, + skip: Option, +} #[actix_web::test] async fn example_applications_render_their_entrypoint() { @@ -48,17 +46,21 @@ async fn example_applications_render_their_entrypoint() { let mysql_base = prepare_mysql_base_url().await; for example in example_apps() { + if let Some(reason) = &example.skip { + eprintln!("skipping {}: {reason}", example.name); + continue; + } let database_url = match example.backend { Backend::Sqlite => "sqlite::memory:".to_string(), Backend::Postgres => match &postgres_base { - Some(base) => format!("{base}_{}", slug(example.name)), + Some(base) => format!("{base}_{}", slug(&example.name)), None => { eprintln!("skipping {}: PostgreSQL is not available", example.name); continue; } }, Backend::MySql => match &mysql_base { - Some(base) => format!("{base}_{}", slug(example.name)), + Some(base) => format!("{base}_{}", slug(&example.name)), None => { eprintln!("skipping {}: MySQL is not available", example.name); continue; @@ -66,270 +68,83 @@ async fn example_applications_render_their_entrypoint() { }, }; prepare_database(&database_url, example.backend).await; - smoke_example(example, &database_url).await; + smoke_example(&example, &database_url).await; } } #[test] -fn all_example_applications_are_smoked_or_explicitly_skipped() { - let tested = example_apps() - .into_iter() - .map(|example| example.name) - .collect::>(); - let skipped = SKIPPED_EXAMPLES - .iter() - .map(|(name, _reason)| *name) - .collect::>(); - let mut missing = Vec::new(); - for entry in std::fs::read_dir("examples").unwrap() { +fn example_applications_are_discovered_from_the_examples_directory() { + let apps = example_apps(); + assert!(!apps.is_empty(), "no example applications were discovered"); + + let mut names = HashSet::new(); + let mut duplicates = Vec::new(); + for example in apps { + if !names.insert(example.name.clone()) { + duplicates.push(example.name); + } + } + assert!( + duplicates.is_empty(), + "duplicate example applications discovered: {duplicates:?}" + ); +} + +fn example_apps() -> Vec { + let mut apps = Vec::new(); + for entry in std::fs::read_dir(EXAMPLES_DIR).unwrap() { let entry = entry.unwrap(); if !entry.file_type().unwrap().is_dir() { continue; } + let root = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); - if !tested.contains(name.as_str()) && !skipped.contains(name.as_str()) { - missing.push(name); - } + let test_config = read_test_config(&root); + + apps.push(ExampleApp { + name, + web_root: resolve_example_path(&root, test_config.web_root, "."), + config_dir: resolve_example_path(&root, test_config.config_dir, "sqlpage"), + route: test_config.route.unwrap_or_else(|| "/".to_string()), + site_prefix: test_config.site_prefix, + backend: test_config.backend.unwrap_or(Backend::Sqlite), + skip: test_config.skip, + }); } - missing.sort(); - assert!( - missing.is_empty(), - "examples missing from smoke coverage or explicit skip list: {missing:?}" - ); + apps.sort_by(|left, right| left.name.cmp(&right.name)); + apps } -fn example_apps() -> [ExampleApp; 31] { - [ - ExampleApp { - name: "CRUD - Authentication", - web_root: "examples/CRUD - Authentication/www", - config_dir: "examples/CRUD - Authentication/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "cards-with-remote-content", - web_root: "examples/cards-with-remote-content", - config_dir: "examples/cards-with-remote-content/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "charts, computations and custom components", - web_root: "examples/charts, computations and custom components", - config_dir: "examples/charts, computations and custom components/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "corporate-conundrum", - web_root: "examples/corporate-conundrum", - config_dir: "examples/corporate-conundrum/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "forms with a variable number of fields", - web_root: "examples/forms with a variable number of fields", - config_dir: "examples/forms with a variable number of fields/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "forms-with-multiple-steps", - web_root: "examples/forms-with-multiple-steps", - config_dir: "examples/forms-with-multiple-steps/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "handle-404", - web_root: "examples/handle-404", - config_dir: "examples/handle-404/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "image gallery with user uploads", - web_root: "examples/image gallery with user uploads", - config_dir: "examples/image gallery with user uploads/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "light-dark-toggle", - web_root: "examples/light-dark-toggle", - config_dir: "examples/light-dark-toggle/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "master-detail-forms", - web_root: "examples/master-detail-forms", - config_dir: "examples/master-detail-forms/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "modeling a many to many relationship with a form", - web_root: "examples/modeling a many to many relationship with a form", - config_dir: "examples/modeling a many to many relationship with a form/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "multiple-choice-question", - web_root: "examples/multiple-choice-question", - config_dir: "examples/multiple-choice-question/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "official-site", - web_root: "examples/official-site", - config_dir: "examples/official-site/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "plots tables and forms", - web_root: "examples/plots tables and forms", - config_dir: "examples/plots tables and forms/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "read-and-set-http-cookies", - web_root: "examples/read-and-set-http-cookies", - config_dir: "examples/read-and-set-http-cookies/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "rich-text-editor", - web_root: "examples/rich-text-editor", - config_dir: "examples/rich-text-editor/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "roundest_pokemon_rating", - web_root: "examples/roundest_pokemon_rating/src", - config_dir: "examples/roundest_pokemon_rating/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "sending emails", - web_root: "examples/sending emails", - config_dir: "examples/sending emails/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "simple-website-example", - web_root: "examples/simple-website-example", - config_dir: "examples/simple-website-example/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "splitwise", - web_root: "examples/splitwise", - config_dir: "examples/splitwise/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "todo application", - web_root: "examples/todo application", - config_dir: "examples/todo application/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "user-authentication", - web_root: "examples/user-authentication", - config_dir: "examples/user-authentication/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "using react and other custom scripts and styles", - web_root: "examples/using react and other custom scripts and styles", - config_dir: "examples/using react and other custom scripts and styles/sqlpage", - route: "/", - backend: Backend::Sqlite, - }, - ExampleApp { - name: "web servers - apache", - web_root: "examples/web servers - apache/website", - config_dir: "examples/web servers - apache/sqlpage_config", - route: "/my_website/", - backend: Backend::MySql, - }, - ExampleApp { - name: "custom form component", - web_root: "examples/custom form component", - config_dir: "examples/custom form component/sqlpage", - route: "/", - backend: Backend::MySql, - }, - ExampleApp { - name: "mysql json handling", - web_root: "examples/mysql json handling", - config_dir: "examples/mysql json handling/sqlpage", - route: "/", - backend: Backend::MySql, - }, - ExampleApp { - name: "nginx", - web_root: "examples/nginx/website", - config_dir: "examples/nginx/sqlpage_config", - route: "/", - backend: Backend::MySql, - }, - ExampleApp { - name: "SQLPage developer user interface", - web_root: "examples/SQLPage developer user interface/website", - config_dir: "examples/SQLPage developer user interface/sqlpage", - route: "/", - backend: Backend::Postgres, - }, - ExampleApp { - name: "telemetry", - web_root: "examples/telemetry/website", - config_dir: "examples/telemetry/sqlpage", - route: "/", - backend: Backend::Postgres, - }, - ExampleApp { - name: "tiny_twitter", - web_root: "examples/tiny_twitter", - config_dir: "examples/tiny_twitter/sqlpage", - route: "/", - backend: Backend::Postgres, - }, - ExampleApp { - name: "todo application (PostgreSQL)", - web_root: "examples/todo application (PostgreSQL)", - config_dir: "examples/todo application (PostgreSQL)/sqlpage", - route: "/", - backend: Backend::Postgres, - }, - ] +fn read_test_config(root: &Path) -> ExampleTestConfig { + let path = root.join(TEST_CONFIG_FILE); + if !path.exists() { + return ExampleTestConfig::default(); + } + let contents = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err:#}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|err| panic!("failed to parse {}: {err:#}", path.display())) +} + +fn resolve_example_path(root: &Path, configured: Option, default: &str) -> PathBuf { + let path = configured.unwrap_or_else(|| PathBuf::from(default)); + if path.is_absolute() { + path + } else { + root.join(path) + } } -async fn smoke_example(example: ExampleApp, database_url: &str) { +async fn smoke_example(example: &ExampleApp, database_url: &str) { let mut config = crate::common::test_config(); - config.web_root = PathBuf::from(example.web_root); - config.configuration_directory = PathBuf::from(example.config_dir); + config.web_root = example.web_root.clone(); + config.configuration_directory = example.config_dir.clone(); config.database_url = database_url.to_string(); config.max_database_pool_connections = Some(1); config.database_connection_retries = 0; config.sqlite_extensions.clear(); - if example.route.starts_with("/my_website/") { - config.site_prefix = "/my_website/".to_string(); + if let Some(site_prefix) = &example.site_prefix { + config.site_prefix.clone_from(site_prefix); } let state = AppState::init(&config) @@ -340,7 +155,7 @@ async fn smoke_example(example: ExampleApp, database_url: &str) { .unwrap_or_else(|err| panic!("{} migrations failed: {err:#}", example.name)); let data = Data::new(state); let req = actix_test::TestRequest::get() - .uri(example.route) + .uri(&example.route) .app_data(data) .to_srv_request(); let response = main_handler(req) From 7eb01e584697d87e6de5e4474f537e01fd76ea58 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Mon, 1 Jun 2026 11:17:39 +0200 Subject: [PATCH 3/4] Use DATABASE_URL for example database tests --- tests/examples/mod.rs | 74 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/tests/examples/mod.rs b/tests/examples/mod.rs index e7a78ff49..44e521d37 100644 --- a/tests/examples/mod.rs +++ b/tests/examples/mod.rs @@ -177,6 +177,17 @@ async fn smoke_example(example: &ExampleApp, database_url: &str) { } async fn prepare_postgres_base_url() -> Option { + if let Some(database_url) = database_url_from_env(&["postgres", "postgresql"]) { + let admin_url = replace_database_name(&database_url, "postgres"); + let mut conn = sqlx::PgConnection::connect(&admin_url) + .await + .unwrap_or_else(|err| panic!("DATABASE_URL points to PostgreSQL but connecting to {admin_url} failed: {err:#}")); + conn.execute("SELECT 1").await.unwrap_or_else(|err| { + panic!("DATABASE_URL points to PostgreSQL but validation failed: {err:#}") + }); + return Some(replace_database_name(&database_url, "sqlpage_examples")); + } + let admin_url = "postgres://root:Password123!@localhost/postgres"; let mut conn = sqlx::PgConnection::connect(admin_url).await.ok()?; conn.execute("SELECT 1").await.ok()?; @@ -184,6 +195,19 @@ async fn prepare_postgres_base_url() -> Option { } async fn prepare_mysql_base_url() -> Option { + if let Some(database_url) = database_url_from_env(&["mysql"]) { + let admin_url = replace_database_name(&database_url, "mysql"); + let mut conn = sqlx::MySqlConnection::connect(&admin_url) + .await + .unwrap_or_else(|err| { + panic!("DATABASE_URL points to MySQL but connecting to {admin_url} failed: {err:#}") + }); + conn.execute("SELECT 1").await.unwrap_or_else(|err| { + panic!("DATABASE_URL points to MySQL but validation failed: {err:#}") + }); + return Some(replace_database_name(&database_url, "sqlpage_examples")); + } + let mut conn = sqlx::MySqlConnection::connect("mysql://root:Password123!@localhost/mysql") .await .ok()?; @@ -195,11 +219,9 @@ async fn prepare_database(database_url: &str, backend: Backend) { match backend { Backend::Sqlite => {} Backend::Postgres => { - let db_name = database_url.rsplit('/').next().unwrap(); - let mut conn = - sqlx::PgConnection::connect("postgres://root:Password123!@localhost/postgres") - .await - .unwrap(); + let db_name = database_name(database_url); + let admin_url = replace_database_name(database_url, "postgres"); + let mut conn = sqlx::PgConnection::connect(&admin_url).await.unwrap(); let _ = conn .execute(format!("DROP DATABASE IF EXISTS {db_name} WITH (FORCE)").as_str()) .await; @@ -209,11 +231,9 @@ async fn prepare_database(database_url: &str, backend: Backend) { tokio::time::sleep(Duration::from_millis(100)).await; } Backend::MySql => { - let db_name = database_url.rsplit('/').next().unwrap(); - let mut conn = - sqlx::MySqlConnection::connect("mysql://root:Password123!@localhost/mysql") - .await - .unwrap(); + let db_name = database_name(database_url); + let admin_url = replace_database_name(database_url, "mysql"); + let mut conn = sqlx::MySqlConnection::connect(&admin_url).await.unwrap(); conn.execute(format!("DROP DATABASE IF EXISTS {db_name}").as_str()) .await .unwrap(); @@ -225,6 +245,40 @@ async fn prepare_database(database_url: &str, backend: Backend) { } } +fn database_url_from_env(schemes: &[&str]) -> Option { + let database_url = std::env::var("DATABASE_URL").ok()?; + let scheme = database_url.split_once(':')?.0; + schemes.contains(&scheme).then_some(database_url) +} + +fn database_name(database_url: &str) -> &str { + let url_without_query = database_url + .split_once('?') + .map_or(database_url, |(url, _query)| url); + let database_start = url_without_query + .rfind('/') + .unwrap_or_else(|| panic!("database URL has no path: {database_url}")); + &url_without_query[database_start + 1..] +} + +fn replace_database_name(database_url: &str, database_name: &str) -> String { + let (url_without_query, query) = database_url + .split_once('?') + .map_or((database_url, ""), |(url, query)| (url, query)); + let database_start = url_without_query + .rfind('/') + .unwrap_or_else(|| panic!("database URL has no path: {database_url}")); + let query = if query.is_empty() { + String::new() + } else { + format!("?{query}") + }; + format!( + "{}{database_name}{query}", + &url_without_query[..=database_start] + ) +} + fn slug(name: &str) -> String { name.chars() .filter(|c| c.is_ascii_alphanumeric()) From fb8b013eb0d48854d445c732d1e9ea5bf7984bcb Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Tue, 2 Jun 2026 11:25:44 +0200 Subject: [PATCH 4/4] Accept MySQL decimal cast output in SQL fixture --- tests/sql_test_files/data/postgres_cast_syntax.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sql_test_files/data/postgres_cast_syntax.sql b/tests/sql_test_files/data/postgres_cast_syntax.sql index 53ce2ff86..64f45683e 100644 --- a/tests/sql_test_files/data/postgres_cast_syntax.sql +++ b/tests/sql_test_files/data/postgres_cast_syntax.sql @@ -1 +1 @@ -select 2 as expected, $x::decimal + 1 as actual; +select '2' as expected_contains, $x::decimal + 1 as actual;