diff --git a/loco-new/base_template/Cargo.toml.t b/loco-new/base_template/Cargo.toml.t index b54aae8d7..a7fbe94f5 100644 --- a/loco-new/base_template/Cargo.toml.t +++ b/loco-new/base_template/Cargo.toml.t @@ -21,14 +21,15 @@ loco-rs = { {{settings.loco_version_text}} {%- if not settings.features.default_ [dependencies] loco-rs = { workspace = true {% if feature_list | length > 0 %}, features = {{feature_list}}{% endif %} } serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1" } tokio = { version = "1.33.0", default-features = false, features = [ "rt-multi-thread", ] } -async-trait = "0.1.74" -axum = "0.7.5" -tracing = "0.1.40" +async-trait = { version = "0.1.74" } +axum = { version = "0.7.5" } +tracing = { version = "0.1.40" } tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] } +regex = { version = "1.11.1" } {%- if settings.db %} migration = { path = "migration" } sea-orm = { version = "1.1.0", features = [ @@ -37,19 +38,19 @@ sea-orm = { version = "1.1.0", features = [ "runtime-tokio-rustls", "macros", ] } -chrono = "0.4" +chrono = { version = "0.4" } validator = { version = "0.19" } uuid = { version = "1.6.0", features = ["v4"] } {%- endif %} {%- if settings.mailer %} -include_dir = "0.7" +include_dir = { version = "0.7" } {%- endif %} {%- if settings.asset %} # view engine i18n fluent-templates = { version = "0.8.0", features = ["tera"] } -unic-langid = "0.9.4" +unic-langid = { version = "0.9.4" } # /view engine {%- endif %} @@ -67,6 +68,6 @@ required-features = [] [dev-dependencies] loco-rs = { workspace = true, features = ["testing"] } -serial_test = "3.1.1" -rstest = "0.21.0" +serial_test = { version = "3.1.1" } +rstest = { version = "0.21.0" } insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] } diff --git a/loco-new/base_template/config/test.yaml.t b/loco-new/base_template/config/test.yaml.t index b1d1ed535..267c60562 100644 --- a/loco-new/base_template/config/test.yaml.t +++ b/loco-new/base_template/config/test.yaml.t @@ -53,7 +53,7 @@ workers: # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. - mode: {{settings.background.kind}} + mode: ForegroundBlocking {% if settings.background.kind == "BackgroundQueue"%} # Queue Configuration @@ -70,6 +70,7 @@ queue: # Mailer Configuration. mailer: + stub: true # SMTP mailer configuration. smtp: # Enable/Disable smtp mailer. diff --git a/loco-new/base_template/migration/src/lib.rs b/loco-new/base_template/migration/src/lib.rs.t similarity index 81% rename from loco-new/base_template/migration/src/lib.rs rename to loco-new/base_template/migration/src/lib.rs.t index d37c3d1b0..388470d31 100644 --- a/loco-new/base_template/migration/src/lib.rs +++ b/loco-new/base_template/migration/src/lib.rs.t @@ -2,7 +2,9 @@ #![allow(clippy::wildcard_imports)] pub use sea_orm_migration::prelude::*; +{%- if settings.auth %} mod m20220101_000001_users; +{%- endif %} pub struct Migrator; @@ -10,7 +12,9 @@ pub struct Migrator; impl MigratorTrait for Migrator { fn migrations() -> Vec> { vec![ + {%- if settings.auth %} Box::new(m20220101_000001_users::Migration), + {%- endif %} // inject-above (do not remove this comment) ] } diff --git a/loco-new/base_template/migration/src/m20220101_000001_users.rs b/loco-new/base_template/migration/src/m20220101_000001_users.rs index 936ad3d0c..0b689d73e 100644 --- a/loco-new/base_template/migration/src/m20220101_000001_users.rs +++ b/loco-new/base_template/migration/src/m20220101_000001_users.rs @@ -21,6 +21,8 @@ impl MigrationTrait for Migration { Users::EmailVerificationSentAt, )) .col(timestamp_with_time_zone_null(Users::EmailVerifiedAt)) + .col(string_null(Users::MagicLinkToken)) + .col(timestamp_with_time_zone_null(Users::MagicLinkExpiration)) .to_owned(); manager.create_table(table).await?; Ok(()) @@ -47,4 +49,6 @@ pub enum Users { EmailVerificationToken, EmailVerificationSentAt, EmailVerifiedAt, + MagicLinkToken, + MagicLinkExpiration, } diff --git a/loco-new/base_template/src/app.rs.t b/loco-new/base_template/src/app.rs.t index 655adf32b..5414e23c2 100644 --- a/loco-new/base_template/src/app.rs.t +++ b/loco-new/base_template/src/app.rs.t @@ -11,7 +11,7 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, - {%- if settings.db %} + {%- if settings.auth %} db::{self, truncate_table}, {%- endif %} environment::Environment, @@ -25,17 +25,16 @@ use sea_orm::DatabaseConnection; #[allow(unused_imports)] use crate::{ - controllers + controllers ,tasks {%- if settings.initializers -%} , initializers {%- endif %} - {%- if settings.db %} - ,tasks + {%- if settings.auth %} , models::_entities::users {%- endif %} {%- if settings.background %} , workers::downloader::DownloadWorker - {%- endif %}, + {%- endif %} }; pub struct App; @@ -97,13 +96,26 @@ impl Hooks for App { } {%- if settings.db %} + + {%- if settings.auth %} async fn truncate(db: &DatabaseConnection) -> Result<()> { + {%- else %} + async fn truncate(_db: &DatabaseConnection) -> Result<()> { + {%- endif %} + {%- if settings.auth %} truncate_table(db, users::Entity).await?; + {%- endif %} Ok(()) } + {%- if settings.auth %} async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + {%- else %} + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { + {%- endif %} + {%- if settings.auth %} db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + {%- endif %} Ok(()) } {%- endif %} diff --git a/loco-new/base_template/src/controllers/auth.rs b/loco-new/base_template/src/controllers/auth.rs index 27e3de71e..2b6fb5218 100644 --- a/loco-new/base_template/src/controllers/auth.rs +++ b/loco-new/base_template/src/controllers/auth.rs @@ -1,7 +1,3 @@ -use axum::debug_handler; -use loco_rs::prelude::*; -use serde::{Deserialize, Serialize}; - use crate::{ mailers::auth::AuthMailer, models::{ @@ -10,6 +6,20 @@ use crate::{ }, views::auth::{CurrentResponse, LoginResponse}, }; +use axum::debug_handler; +use loco_rs::prelude::*; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +pub static EMAIL_DOMAIN_RE: OnceLock = OnceLock::new(); + +fn get_allow_email_domain_re() -> &'static Regex { + EMAIL_DOMAIN_RE.get_or_init(|| { + Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex") + }) +} + #[derive(Debug, Deserialize, Serialize)] pub struct VerifyParams { pub token: String, @@ -26,6 +36,11 @@ pub struct ResetParams { pub password: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct MagicLinkParams { + pub email: String, +} + /// Register function creates a new user with the given parameters and sends a /// welcome email to the user #[debug_handler] @@ -145,6 +160,68 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result, + Json(params): Json, +) -> Result { + let email_regex = get_allow_email_domain_re(); + if !email_regex.is_match(¶ms.email) { + tracing::debug!( + email = params.email, + "The provided email is invalid or does not match the allowed domains" + ); + return bad_request("invalid request"); + } + + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + tracing::debug!(email = params.email, "user not found by email"); + return format::empty_json(); + }; + + let user = user.into_active_model().create_magic_link(&ctx.db).await?; + AuthMailer::send_magic_link(&ctx, &user).await?; + + format::empty_json() +} + +/// Verifies a magic link token and authenticates the user. +async fn magic_link_verify( + Path(token): Path, + State(ctx): State, +) -> Result { + let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + return unauthorized("unauthorized!"); + }; + + let user = user.into_active_model().clear_magic_link(&ctx.db).await?; + + let jwt_secret = ctx.config.get_jwt_config()?; + + let token = user + .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::json(LoginResponse::new(&user, &token)) +} + pub fn routes() -> Routes { Routes::new() .prefix("/api/auth") @@ -154,4 +231,6 @@ pub fn routes() -> Routes { .add("/forgot", post(forgot)) .add("/reset", post(reset)) .add("/current", get(current)) + .add("/magic-link", post(magic_link)) + .add("/magic-link/:token", get(magic_link_verify)) } diff --git a/loco-new/base_template/src/mailers/auth.rs b/loco-new/base_template/src/mailers/auth.rs index 30bb1bf2f..f2d635232 100644 --- a/loco-new/base_template/src/mailers/auth.rs +++ b/loco-new/base_template/src/mailers/auth.rs @@ -8,6 +8,7 @@ use crate::models::users; static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); +static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link"); // #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets // move on for now. @@ -62,4 +63,30 @@ impl AuthMailer { Ok(()) } + + /// Sends a magic link authentication email to the user. + /// + /// # Errors + /// + /// When email sending is failed + pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &magic_link, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "token": user.magic_link_token.clone().ok_or_else(|| Error::string( + "the user model not contains magic link token", + ))?, + "host": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } } diff --git a/loco-new/base_template/src/mailers/auth/magic_link/html.t b/loco-new/base_template/src/mailers/auth/magic_link/html.t new file mode 100644 index 000000000..56eb2527c --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/html.t @@ -0,0 +1,8 @@ +; + +

Magic link example:

+ +Verify Your Account + + + diff --git a/loco-new/base_template/src/mailers/auth/magic_link/subject.t b/loco-new/base_template/src/mailers/auth/magic_link/subject.t new file mode 100644 index 000000000..93eaba77c --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/subject.t @@ -0,0 +1 @@ +Magic link example diff --git a/loco-new/base_template/src/mailers/auth/magic_link/text.t b/loco-new/base_template/src/mailers/auth/magic_link/text.t new file mode 100644 index 000000000..b33d33106 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/text.t @@ -0,0 +1,2 @@ +Magic link with this link: +{{host}}/api/auth/magic-link/{{token}} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/mod.rs b/loco-new/base_template/src/models/_entities/mod.rs.t similarity index 71% rename from loco-new/base_template/src/models/_entities/mod.rs rename to loco-new/base_template/src/models/_entities/mod.rs.t index 095dade0f..dfdb3aff5 100644 --- a/loco-new/base_template/src/models/_entities/mod.rs +++ b/loco-new/base_template/src/models/_entities/mod.rs.t @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 pub mod prelude; - +{%- if settings.auth %} pub mod users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/prelude.rs b/loco-new/base_template/src/models/_entities/prelude.rs.t similarity index 72% rename from loco-new/base_template/src/models/_entities/prelude.rs rename to loco-new/base_template/src/models/_entities/prelude.rs.t index 4036adeec..2896e25a3 100644 --- a/loco-new/base_template/src/models/_entities/prelude.rs +++ b/loco-new/base_template/src/models/_entities/prelude.rs.t @@ -1,3 +1,5 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +{%- if settings.auth %} pub use super::users::Entity as Users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/users.rs b/loco-new/base_template/src/models/_entities/users.rs index 120b1a1b1..765e99272 100644 --- a/loco-new/base_template/src/models/_entities/users.rs +++ b/loco-new/base_template/src/models/_entities/users.rs @@ -22,6 +22,8 @@ pub struct Model { pub email_verification_token: Option, pub email_verification_sent_at: Option, pub email_verified_at: Option, + pub magic_link_token: Option, + pub magic_link_expiration: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/loco-new/base_template/src/models/mod.rs b/loco-new/base_template/src/models/mod.rs deleted file mode 100644 index 48da463b6..000000000 --- a/loco-new/base_template/src/models/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod _entities; -pub mod users; diff --git a/loco-new/base_template/src/models/mod.rs.t b/loco-new/base_template/src/models/mod.rs.t new file mode 100644 index 000000000..9f2e96c67 --- /dev/null +++ b/loco-new/base_template/src/models/mod.rs.t @@ -0,0 +1,4 @@ +pub mod _entities; +{%- if settings.auth %} +pub mod users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/models/users.rs b/loco-new/base_template/src/models/users.rs index b4f3aaea0..2855e1f9e 100644 --- a/loco-new/base_template/src/models/users.rs +++ b/loco-new/base_template/src/models/users.rs @@ -1,11 +1,14 @@ use async_trait::async_trait; -use chrono::offset::Local; +use chrono::{offset::Local, Duration}; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; use uuid::Uuid; pub use super::_entities::users::{self, ActiveModel, Entity, Model}; +pub const MAGIC_LINK_LENGTH: i8 = 32; +pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; + #[derive(Debug, Deserialize, Serialize)] pub struct LoginParams { pub email: String, @@ -27,7 +30,7 @@ pub struct Validator { pub email: String, } -impl Validatable for super::_entities::users::ActiveModel { +impl Validatable for ActiveModel { fn validator(&self) -> Box { Box::new(Validator { name: self.name.as_ref().to_owned(), @@ -55,7 +58,7 @@ impl ActiveModelBehavior for super::_entities::users::ActiveModel { } #[async_trait] -impl Authenticable for super::_entities::users::Model { +impl Authenticable for Model { async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { let user = users::Entity::find() .filter( @@ -73,7 +76,7 @@ impl Authenticable for super::_entities::users::Model { } } -impl super::_entities::users::Model { +impl Model { /// finds a user by the provided email /// /// # Errors @@ -111,6 +114,42 @@ impl super::_entities::users::Model { user.ok_or_else(|| ModelError::EntityNotFound) } + /// finds a user by the magic token and verify and token expiration + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error ot token expired + pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + query::condition() + .eq(users::Column::MagicLinkToken, token) + .build(), + ) + .one(db) + .await?; + + let user = user.ok_or_else(|| ModelError::EntityNotFound)?; + if let Some(expired_at) = user.magic_link_expiration { + if expired_at >= Local::now() { + Ok(user) + } else { + tracing::debug!( + user_pid = user.pid.to_string(), + token_expiration = expired_at.to_string(), + "magic token expired for the user." + ); + Err(ModelError::msg("magic token expired")) + } + } else { + tracing::error!( + user_pid = user.pid.to_string(), + "magic link expiration time not exists" + ); + Err(ModelError::msg("expiration token not exists")) + } + } + /// finds a user by the provided reset token /// /// # Errors @@ -224,7 +263,7 @@ impl super::_entities::users::Model { } } -impl super::_entities::users::ActiveModel { +impl ActiveModel { /// Sets the email verification information for the user and /// updates it in the database. /// @@ -295,4 +334,33 @@ impl super::_entities::users::ActiveModel { self.reset_sent_at = ActiveValue::Set(None); Ok(self.update(db).await?) } + + /// Creates a magic link token for passwordless authentication. + /// + /// Generates a random token with a specified length and sets an expiration time + /// for the magic link. This method is used to initiate the magic link authentication flow. + /// + /// # Errors + /// - Returns an error if database update fails + pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { + let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize); + let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into()); + + self.magic_link_token = ActiveValue::set(Some(random_str)); + self.magic_link_expiration = ActiveValue::set(Some(expired.into())); + Ok(self.update(db).await?) + } + + /// Verifies and invalidates the magic link after successful authentication. + /// + /// Clears the magic link token and expiration time after the user has + /// successfully authenticated using the magic link. + /// + /// # Errors + /// - Returns an error if database update fails + pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { + self.magic_link_token = ActiveValue::set(None); + self.magic_link_expiration = ActiveValue::set(None); + Ok(self.update(db).await?) + } } diff --git a/loco-new/base_template/tests/models/mod.rs b/loco-new/base_template/tests/models/mod.rs deleted file mode 100644 index 59759880d..000000000 --- a/loco-new/base_template/tests/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod users; diff --git a/loco-new/base_template/tests/models/mod.rs.t b/loco-new/base_template/tests/models/mod.rs.t new file mode 100644 index 000000000..1a3a0a456 --- /dev/null +++ b/loco-new/base_template/tests/models/mod.rs.t @@ -0,0 +1,3 @@ +{%- if settings.auth %} +mod users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap index 6e66fd35a..98113622d 100644 --- a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap @@ -17,5 +17,7 @@ Ok( email_verification_token: None, email_verification_sent_at: None, email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap index 067d0e752..518753a67 100644 --- a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap @@ -17,5 +17,7 @@ Ok( email_verification_token: None, email_verification_sent_at: None, email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap index 067d0e752..518753a67 100644 --- a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap @@ -17,5 +17,7 @@ Ok( email_verification_token: None, email_verification_sent_at: None, email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/models/users.rs.t b/loco-new/base_template/tests/models/users.rs.t index 7ace87eca..16f845b6b 100644 --- a/loco-new/base_template/tests/models/users.rs.t +++ b/loco-new/base_template/tests/models/users.rs.t @@ -1,5 +1,6 @@ +use chrono::{offset::Local, Duration}; use insta::assert_debug_snapshot; -use loco_rs::{model::ModelError, testing::prelude::*}; +use loco_rs::testing::prelude::*; use {{settings.module_name}}::{ app::App, models::users::{self, Model, RegisterParams}, @@ -21,15 +22,15 @@ macro_rules! configure_insta { async fn test_can_validate_model() { configure_insta!(); - let boot = boot_test::().await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); - let res = users::ActiveModel { + let invalid_user = users::ActiveModel { name: ActiveValue::set("1".to_string()), email: ActiveValue::set("invalid-email".to_string()), ..Default::default() - } - .insert(&boot.app_context.db) - .await; + }; + + let res = invalid_user.insert(&boot.app_context.db).await; assert_debug_snapshot!(res); } @@ -39,13 +40,14 @@ async fn test_can_validate_model() { async fn can_create_with_password() { configure_insta!(); - let boot = boot_test::().await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); let params = RegisterParams { email: "test@framework.com".to_string(), password: "1234".to_string(), name: "framework".to_string(), }; + let res = Model::create_with_password(&boot.app_context.db, ¶ms).await; insta::with_settings!({ @@ -54,16 +56,15 @@ async fn can_create_with_password() { assert_debug_snapshot!(res); }); } - #[tokio::test] #[serial] async fn handle_create_with_password_with_duplicate() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); - let new_user: Result = Model::create_with_password( + let new_user = Model::create_with_password( &boot.app_context.db, &RegisterParams { email: "user1@example.com".to_string(), @@ -72,6 +73,7 @@ async fn handle_create_with_password_with_duplicate() { }, ) .await; + assert_debug_snapshot!(new_user); } @@ -80,12 +82,11 @@ async fn handle_create_with_password_with_duplicate() { async fn can_find_by_email() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await; - let non_existing_user_results = - Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; + let non_existing_user_results = Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; assert_debug_snapshot!(existing_user); assert_debug_snapshot!(non_existing_user_results); @@ -96,13 +97,11 @@ async fn can_find_by_email() { async fn can_find_by_pid() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); - let existing_user = - Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; - let non_existing_user_results = - Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; + let existing_user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; + let non_existing_user_results = Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; assert_debug_snapshot!(existing_user); assert_debug_snapshot!(non_existing_user_results); @@ -113,111 +112,185 @@ async fn can_find_by_pid() { async fn can_verification_token() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.email_verification_sent_at.is_none()); - assert!(user.email_verification_token.is_none()); + assert!(user.email_verification_sent_at.is_none(), "Expected no email verification sent timestamp"); + assert!(user.email_verification_token.is_none(), "Expected no email verification token"); - assert!(user + let result = user .into_active_model() .set_email_verification_sent(&boot.app_context.db) - .await - .is_ok()); + .await; + + assert!(result.is_ok(), "Failed to set email verification sent"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID after setting verification sent"); - assert!(user.email_verification_sent_at.is_some()); - assert!(user.email_verification_token.is_some()); + assert!(user.email_verification_sent_at.is_some(), "Expected email verification sent timestamp to be present"); + assert!(user.email_verification_token.is_some(), "Expected email verification token to be present"); } + #[tokio::test] #[serial] async fn can_set_forgot_password_sent() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.reset_sent_at.is_none()); - assert!(user.reset_token.is_none()); + assert!(user.reset_sent_at.is_none(), "Expected no reset sent timestamp"); + assert!(user.reset_token.is_none(), "Expected no reset token"); - assert!(user + let result = user .into_active_model() .set_forgot_password_sent(&boot.app_context.db) - .await - .is_ok()); + .await; + + assert!(result.is_ok(), "Failed to set forgot password sent"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID after setting forgot password sent"); - assert!(user.reset_sent_at.is_some()); - assert!(user.reset_token.is_some()); + assert!(user.reset_sent_at.is_some(), "Expected reset sent timestamp to be present"); + assert!(user.reset_token.is_some(), "Expected reset token to be present"); } + #[tokio::test] #[serial] async fn can_verified() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.email_verified_at.is_none()); + assert!(user.email_verified_at.is_none(), "Expected email to be unverified"); - assert!(user + let result = user .into_active_model() .verified(&boot.app_context.db) - .await - .is_ok()); + .await; + + assert!(result.is_ok(), "Failed to mark email as verified"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID after verification"); - assert!(user.email_verified_at.is_some()); + assert!(user.email_verified_at.is_some(), "Expected email to be verified"); } + #[tokio::test] #[serial] async fn can_reset_password() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.verify_password("12341234")); + assert!(user.verify_password("12341234"), "Password verification failed for original password"); - assert!(user + let result = user .clone() .into_active_model() .reset_password(&boot.app_context.db, "new-password") + .await; + + assert!(result.is_ok(), "Failed to reset password"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .is_ok()); + .expect("Failed to find user by PID after password reset"); + + assert!(user.verify_password("new-password"), "Password verification failed for new password"); +} + + +#[tokio::test] +#[serial] +async fn magic_link() { + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context.db).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!( + user.magic_link_token.is_none(), + "Magic link token should be initially unset" + ); + assert!( + user.magic_link_expiration.is_none(), + "Magic link expiration should be initially unset" + ); + + let create_result = user + .into_active_model() + .create_magic_link(&boot.app_context.db) + .await; assert!( + create_result.is_ok(), + "Failed to create magic link: {:?}", + create_result.unwrap_err() + ); + + let updated_user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap() - .verify_password("new-password") + .expect("Failed to refetch user after magic link creation"); + + assert!( + updated_user.magic_link_token.is_some(), + "Magic link token should be set after creation" ); -} + + let magic_link_token = updated_user.magic_link_token.unwrap(); + assert_eq!( + magic_link_token.len(), + users::MAGIC_LINK_LENGTH as usize, + "Magic link token length does not match expected length" + ); + + assert!( + updated_user.magic_link_expiration.is_some(), + "Magic link expiration should be set after creation" + ); + + let now = Local::now(); + let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into()); + let actual_expiration = updated_user.magic_link_expiration.unwrap(); + + assert!( + actual_expiration >= now, + "Magic link expiration should be in the future or now" + ); + + assert!( + actual_expiration <= should_expired_at, + "Magic link expiration exceeds expected maximum expiration time" + ); +} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/auth.rs.t b/loco-new/base_template/tests/requests/auth.rs.t index c7e37da7d..a15c971e3 100644 --- a/loco-new/base_template/tests/requests/auth.rs.t +++ b/loco-new/base_template/tests/requests/auth.rs.t @@ -30,7 +30,8 @@ async fn can_register() { "password": "12341234" }); - let _response = request.post("/api/auth/register").json(&payload).await; + let response = request.post("/api/auth/register").json(&payload).await; + assert_eq!(response.status_code(), 200, "Register request should succeed"); let saved_user = users::Model::find_by_email(&ctx.db, email).await; with_settings!({ @@ -39,11 +40,14 @@ async fn can_register() { assert_debug_snapshot!(saved_user); }); - with_settings!({ - filters => cleanup_email() - }, { - assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); - }); + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + // with_settings!({ + // filters => cleanup_email() + // }, { + // assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); + // }); }) .await; } @@ -65,11 +69,13 @@ async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) }); //Creating a new user - _ = request + let register_response = request .post("/api/auth/register") .json(®ister_payload) .await; + assert_eq!(register_response.status_code(), 200, "Register request should succeed"); + let user = users::Model::find_by_email(&ctx.db, email).await.unwrap(); let verify_payload = serde_json::json!({ "token": user.email_verification_token, @@ -86,11 +92,15 @@ async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) .await; // Make sure email_verified_at is set - assert!(users::Model::find_by_email(&ctx.db, email) + let user = users::Model::find_by_email(&ctx.db, email) .await - .unwrap() - .email_verified_at - .is_some()); + .expect("Failed to find user by email"); + + assert!( + user.email_verified_at.is_some(), + "Expected the email to be verified, but it was not. User: {:?}", + user + ); with_settings!({ filters => cleanup_user_model() @@ -116,13 +126,15 @@ async fn can_login_without_verify() { }); //Creating a new user - _ = request + let register_response = request .post("/api/auth/register") .json(®ister_payload) .await; + assert_eq!(register_response.status_code(), 200, "Register request should succeed"); + //verify user request - let response = request + let login_response = request .post("/api/auth/login") .json(&serde_json::json!({ "email": email, @@ -130,10 +142,12 @@ async fn can_login_without_verify() { })) .await; + assert_eq!(login_response.status_code(), 200, "Login request should succeed"); + with_settings!({ filters => cleanup_user_model() }, { - assert_debug_snapshot!((response.status_code(), response.text())); + assert_debug_snapshot!(login_response.text()); }); }) .await; @@ -150,13 +164,21 @@ async fn can_reset_password() { let forgot_payload = serde_json::json!({ "email": login_data.user.email, }); - _ = request.post("/api/auth/forgot").json(&forgot_payload).await; + let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await; + assert_eq!(forget_response.status_code(), 200, "Forget request should succeed"); let user = users::Model::find_by_email(&ctx.db, &login_data.user.email) .await - .unwrap(); - assert!(user.reset_token.is_some()); - assert!(user.reset_sent_at.is_some()); + .expect("Failed to find user by email"); + + assert!( + user.reset_token.is_some(), + "Expected reset_token to be set, but it was None. User: {user:?}" + ); + assert!( + user.reset_sent_at.is_some(), + "Expected reset_sent_at to be set, but it was None. User: {user:?}" + ); let new_password = "new-password"; let reset_payload = serde_json::json!({ @@ -165,6 +187,7 @@ async fn can_reset_password() { }); let reset_response = request.post("/api/auth/reset").json(&reset_payload).await; + assert_eq!(reset_response.status_code(), 200, "Reset password request should succeed"); let user = users::Model::find_by_email(&ctx.db, &user.email) .await @@ -173,9 +196,9 @@ async fn can_reset_password() { assert!(user.reset_token.is_none()); assert!(user.reset_sent_at.is_none()); - assert_debug_snapshot!((reset_response.status_code(), reset_response.text())); + assert_debug_snapshot!(reset_response.text()); - let response = request + let login_response = request .post("/api/auth/login") .json(&serde_json::json!({ "email": user.email, @@ -183,13 +206,15 @@ async fn can_reset_password() { })) .await; - assert_eq!(response.status_code(), 200); + assert_eq!(login_response.status_code(), 200, "Login request should succeed"); - with_settings!({ - filters => cleanup_email() - }, { - assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); - }); + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 2, "Exactly one email should be sent"); + // with_settings!({ + // filters => cleanup_email() + // }, { + // assert_debug_snapshot!(deliveries.messages); + // }); }) .await; } @@ -208,6 +233,8 @@ async fn can_get_current_user() { .add_header(auth_key, auth_value) .await; + assert_eq!(response.status_code(), 200, "Current request should succeed"); + with_settings!({ filters => cleanup_user_model() }, { @@ -216,3 +243,85 @@ async fn can_get_current_user() { }) .await; } + +#[tokio::test] +#[serial] +async fn can_auth_with_magic_link() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx.db).await.unwrap(); + + let payload = serde_json::json!({ + "email": "user1@example.com", + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!(response.status_code(), 200, "Magic link request should succeed"); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + // let redact_token = format!("[a-zA-Z0-9]{% raw %}{{{}}}{% endraw %}", users::MAGIC_LINK_LENGTH); + // with_settings!({ + // filters => { + // let mut combined_filters = cleanup_email().clone(); + // combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]); + // combined_filters + // } + // }, { + // assert_debug_snapshot!(deliveries.messages); + // }); + + let user = users::Model::find_by_email(&ctx.db, "user1@example.com") + .await + .expect("User should be found"); + + let magic_link_token = user.magic_link_token + .expect("Magic link token should be generated"); + let magic_link_response = request.get(&format!("/api/auth/magic-link/{magic_link_token}")).await; + assert_eq!(magic_link_response.status_code(), 200, "Magic link authentication should succeed"); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(magic_link_response.text()); + }); + + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_email() { + configure_insta!(); + request::(|request, _ctx| async move { + let invalid_email = "user1@temp-mail.com"; + let payload = serde_json::json!({ + "email": invalid_email, + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!( + response.status_code(), + 400, + "Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed." + ); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_magic_link_token() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx.db).await.unwrap(); + + let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await; + assert_eq!( + magic_link_response.status_code(), + 401, + "Magic link authentication should be rejected" + ); + }) + .await; +} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap new file mode 100644 index 000000000..199985703 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: magic_link_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}" diff --git a/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap index ef54ba671..1fac2fd22 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap @@ -1,8 +1,5 @@ --- source: tests/requests/auth.rs -expression: "(response.status_code(), response.text())" +expression: login_response.text() --- -( - 200, - "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}", -) +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}" diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap deleted file mode 100644 index f380dd9f0..000000000 --- a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/requests/auth.rs -expression: ctx.mailer.unwrap().deliveries() ---- -Deliveries { - count: 0, - messages: [], -} diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap index 0c0e13bb7..687580cd7 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap @@ -21,5 +21,7 @@ Ok( DATE, ), email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap deleted file mode 100644 index f380dd9f0..000000000 --- a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/requests/auth.rs -expression: ctx.mailer.unwrap().deliveries() ---- -Deliveries { - count: 0, - messages: [], -} diff --git a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap index be6838d35..d42607978 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap @@ -2,7 +2,4 @@ source: tests/requests/auth.rs expression: "(reset_response.status_code(), reset_response.text())" --- -( - 200, - "null", -) +"null" \ No newline at end of file diff --git a/loco-new/setup.rhai b/loco-new/setup.rhai index 3e780a2fd..802e835b3 100644 --- a/loco-new/setup.rhai +++ b/loco-new/setup.rhai @@ -51,16 +51,34 @@ gen.copy_file("config/production.yaml"); // Production config // Database-Related Files // ===================== if db { - gen.copy_template_dir("migration"); // Database migrations directory - gen.copy_dir("src/models"); // Models directory, copied if background enabled - gen.copy_dir("src/fixtures"); // Database fixtures directory - gen.copy_template("examples/playground.rs.t"); // Example playground template with DB setup - - // Test modules related to database models - gen.copy_file("tests/models/mod.rs"); // Models tests root - gen.copy_dir("tests/models/snapshots"); // Test snapshots for models - gen.copy_template("tests/models/users.rs.t"); // User model test template - gen.copy_template("tests/requests/prepare_data.rs.t"); // Data preparation template + // Database migrations configuration and setup + gen.copy_template("migration/Cargo.toml.t"); // Database migrations Cargo configuration + gen.copy_template("migration/src/lib.rs.t"); // Database migrations library + + // Entity modules for database models + gen.copy_template("src/models/_entities/mod.rs.t"); // Root module for database entities + gen.copy_template("src/models/_entities/prelude.rs.t"); // Root module for database entities + gen.copy_template("src/models/mod.rs.t"); // Root module for database entities + + // Test modules related to database models + gen.copy_template("tests/models/mod.rs.t"); // Models tests root module + + if (settings.auth) { + // Authentication-related models and migrations + gen.copy_file("migration/src/m20220101_000001_users.rs"); // Users migration file + gen.copy_file("src/models/_entities/users.rs"); // Users entity definition + gen.copy_file("src/models/users.rs"); // Users model logic + + // Fixtures and test setup for authentication + gen.copy_dir("src/fixtures"); // Database fixtures directory + + // Test modules related to user models + gen.copy_dir("tests/models/snapshots"); // Test snapshots for models + gen.copy_template("tests/models/users.rs.t"); // User model test template + gen.copy_template("tests/requests/prepare_data.rs.t"); // Data preparation template for tests + } + + gen.copy_template("examples/playground.rs.t"); // Example playground template with DB setup } // ===================== diff --git a/loco-new/src/settings.rs b/loco-new/src/settings.rs index 28df3f5fc..b142b09be 100644 --- a/loco-new/src/settings.rs +++ b/loco-new/src/settings.rs @@ -75,8 +75,8 @@ impl Settings { Self { package_name: package_name.to_string(), module_name: package_name.to_snake_case(), - auth: prompt_selection.db.enable(), - mailer: prompt_selection.db.enable(), + auth: prompt_selection.db.enable() && prompt_selection.background.enable(), + mailer: prompt_selection.db.enable() && prompt_selection.background.enable(), db: prompt_selection.db.clone().into(), background: prompt_selection.background.clone().into(), asset: prompt_selection.asset.clone().into(), diff --git a/loco-new/src/wizard.rs b/loco-new/src/wizard.rs index 61971e100..16ba8c699 100644 --- a/loco-new/src/wizard.rs +++ b/loco-new/src/wizard.rs @@ -318,24 +318,31 @@ pub fn start(args: &ArgsPlaceholder) -> crate::Result { }), Template::RestApi => Ok(Selections { db: select_db(args)?, - background: select_background(args)?, + background: select_background(args, None)?, asset: AssetsOption::None, }), Template::SaasServerSideRendering => Ok(Selections { db: select_db(args)?, - background: select_background(args)?, + background: select_background(args, None)?, asset: AssetsOption::Serverside, }), Template::SaasClientSideRendering => Ok(Selections { db: select_db(args)?, - background: select_background(args)?, + background: select_background(args, None)?, asset: AssetsOption::Clientside, }), - Template::Advanced => Ok(Selections { - db: select_db(args)?, - background: select_background(args)?, - asset: select_asset(args)?, - }), + Template::Advanced => { + let db = select_db(args)?; + let background_options = match db { + DBOption::Sqlite | DBOption::Postgres => Some(vec![BackgroundOption::None]), + DBOption::None => None, + }; + Ok(Selections { + db, + background: select_background(args, background_options.as_ref())?, + asset: select_asset(args)?, + }) + } } } @@ -355,14 +362,18 @@ fn select_db(args: &ArgsPlaceholder) -> crate::Result { /// Prompts the user to select a background worker option if none is provided in /// the arguments. -fn select_background(args: &ArgsPlaceholder) -> crate::Result { +fn select_background( + args: &ArgsPlaceholder, + filters: Option<&Vec>, +) -> crate::Result { let bgopt = if let Some(bgopt) = args.bg.clone() { bgopt } else { - select_option( - "❯ Select your background worker type", - &BackgroundOption::iter().collect::>(), - )? + let available_options = BackgroundOption::iter() + .filter(|opt| filters.as_ref().map_or(true, |f| !f.contains(opt))) + .collect::>(); + + select_option("❯ Select your background worker type", &available_options)? }; Ok(bgopt) } diff --git a/loco-new/tests/templates/auth.rs b/loco-new/tests/templates/auth.rs index 90cbf3d47..6bf2f16a0 100644 --- a/loco-new/tests/templates/auth.rs +++ b/loco-new/tests/templates/auth.rs @@ -1,14 +1,15 @@ -use loco::settings; +use loco::{settings, wizard::DBOption}; use rstest::rstest; use super::*; use crate::assertion; -pub fn run_generator(enable_auth: bool) -> TestGenerator { +pub fn run_generator(enable_auth: bool, db: DBOption) -> TestGenerator { let settings = settings::Settings { package_name: "loco-app-test".to_string(), module_name: "loco_app_test".to_string(), auth: enable_auth, + db: db.into(), ..Default::default() }; @@ -19,7 +20,7 @@ pub fn run_generator(enable_auth: bool) -> TestGenerator { fn test_config_file_without_auth( #[values("config/development.yaml", "config/test.yaml")] config_file: &str, ) { - let generator = run_generator(false); + let generator = run_generator(false, DBOption::None); let content = assertion::yaml::load(generator.path(config_file)); assertion::yaml::assert_path_is_empty(&content, &["auth"]); } @@ -28,7 +29,7 @@ fn test_config_file_without_auth( fn test_config_file_with_auth( #[values("config/development.yaml", "config/test.yaml")] config_file: &str, ) { - let generator = run_generator(true); + let generator = run_generator(true, DBOption::None); let content = assertion::yaml::load(generator.path(config_file)); assertion::yaml::assert_path_key_count(&content, &["auth"], 1); @@ -37,7 +38,7 @@ fn test_config_file_with_auth( #[test] fn test_config_file_development_rand_secret() { - let generator = run_generator(true); + let generator = run_generator(true, DBOption::None); let content = assertion::yaml::load(generator.path("config/development.yaml")); assertion::yaml::assert_path_value_eq_string( &content, @@ -48,7 +49,7 @@ fn test_config_file_development_rand_secret() { #[test] fn test_config_file_test_rand_secret() { - let generator = run_generator(true); + let generator = run_generator(true, DBOption::None); let content = assertion::yaml::load(generator.path("config/test.yaml")); assertion::yaml::assert_path_value_eq_string( &content, @@ -58,17 +59,20 @@ fn test_config_file_test_rand_secret() { } #[rstest] -fn test_app_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); +fn test_app_rs( + #[values(true, false)] auth: bool, + #[values(DBOption::None, DBOption::Sqlite)] db: DBOption, +) { + let generator = run_generator(auth, db.clone()); insta::assert_snapshot!( - format!("src_app_rs_auth_{:?}", auth), + format!("src_app_rs_auth_{:?}_{:?}", auth, db), std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") ); } #[rstest] fn test_src_controllers_mod_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); + let generator = run_generator(auth, DBOption::None); let content = std::fs::read_to_string(generator.path("src/controllers/mod.rs")) .expect("could not open file"); @@ -81,7 +85,7 @@ fn test_src_controllers_mod_rs(#[values(true, false)] auth: bool) { #[rstest] fn test_src_views_mod_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); + let generator = run_generator(auth, DBOption::None); let content = std::fs::read_to_string(generator.path("src/views/mod.rs")).expect("could not open file"); @@ -91,9 +95,10 @@ fn test_src_views_mod_rs(#[values(true, false)] auth: bool) { assertion::string::assert_line_regex(&content, "(?m)^pub mod home;$"); } } + #[rstest] fn test_tests_requests_mod_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); + let generator = run_generator(auth, DBOption::None); let content = std::fs::read_to_string(generator.path("tests/requests/mod.rs")) .expect("could not open file"); @@ -104,3 +109,67 @@ fn test_tests_requests_mod_rs(#[values(true, false)] auth: bool) { assertion::string::assert_line_regex(&content, "(?m)^mod home;$"); } } + +#[rstest] +fn test_migration_src_lib(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("migration/src/lib.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^mod m20220101_000001_users;$"); + assertion::string::assert_line_regex( + &content, + r"(?m)Box::new\(m20220101_000001_users::Migration\),$", + ); + } +} + +#[rstest] +fn test_models_mod_rs(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = + std::fs::read_to_string(generator.path("src/models/mod.rs")).expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^pub mod users;$"); + } +} + +#[rstest] +fn test_models_entities_mod_rs(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("src/models/_entities/mod.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^pub mod users;$"); + } +} + +#[rstest] +fn test_models_entities_prelude_rs(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("src/models/_entities/prelude.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex( + &content, + "(?m)^pub use super::users::Entity as Users;$", + ); + } +} + +#[rstest] +fn test_tests_models_mod_rs(#[values(true, false)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("tests/models/mod.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^mod users;$"); + } else { + assert!(content.is_empty()); + } +} diff --git a/loco-new/tests/templates/background.rs b/loco-new/tests/templates/background.rs index adcab5cba..222b95516 100644 --- a/loco-new/tests/templates/background.rs +++ b/loco-new/tests/templates/background.rs @@ -60,7 +60,7 @@ fn test_config_file_queue( #[rstest] fn test_config_file_workers( - #[values("config/development.yaml", "config/test.yaml")] config_file: &str, + #[values("config/development.yaml")] config_file: &str, #[values( BackgroundOption::None, BackgroundOption::Async, @@ -104,6 +104,51 @@ fn test_config_file_workers( } } +#[rstest] +fn test_config_file_workers_tests( + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + let content = assertion::yaml::load(generator.path("config/test.yaml")); + + match background { + BackgroundOption::Async => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::Queue => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::Blocking => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::None => { + assertion::yaml::assert_path_is_empty(&content, &["workers"]); + } + }; + + if background.enable() { + assertion::yaml::assert_path_key_count(&content, &["workers"], 1); + } +} + #[rstest] fn test_app_rs( #[values( diff --git a/loco-new/tests/templates/mailer.rs b/loco-new/tests/templates/mailer.rs index 17148184d..4f7185fe6 100644 --- a/loco-new/tests/templates/mailer.rs +++ b/loco-new/tests/templates/mailer.rs @@ -28,7 +28,13 @@ fn test_config_file_with_mailer( ) { let generator = run_generator(true); let content = assertion::yaml::load(generator.path(config_file)); - assertion::yaml::assert_path_key_count(&content, &["mailer"], 1); + if config_file == "config/test.yaml" { + assertion::yaml::assert_path_key_count(&content, &["mailer"], 2); + assertion::yaml::assert_path_value_eq_bool(&content, &["mailer", "stub"], true); + } else { + assertion::yaml::assert_path_key_count(&content, &["mailer"], 1); + } + assertion::yaml::assert_path_key_count(&content, &["mailer", "smtp"], 4); assertion::yaml::assert_path_value_eq_bool(&content, &["mailer", "smtp", "enable"], true); assertion::yaml::assert_path_value_eq_int(&content, &["mailer", "smtp", "port"], 1025); diff --git a/loco-new/tests/templates/module_name.rs b/loco-new/tests/templates/module_name.rs index 1291b0bbd..14efcf765 100644 --- a/loco-new/tests/templates/module_name.rs +++ b/loco-new/tests/templates/module_name.rs @@ -48,7 +48,16 @@ fn test_use_name(#[values("src/bin/main.rs", "tests/requests/home.rs")] file: &s fn test_use_name_with_db( #[values("tests/models/users.rs", "tests/requests/prepare_data.rs")] file: &str, ) { - let generator = super::db::run_generator(DBOption::Sqlite); + let settings = settings::Settings { + package_name: "loco-app-test".to_string(), + module_name: "loco_app_test".to_string(), + db: DBOption::Sqlite.into(), + auth: true, + ..Default::default() + }; + + let generator = TestGenerator::generate(settings); + println!("{:#?}", generator.tree); let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); @@ -57,7 +66,7 @@ fn test_use_name_with_db( #[rstest] fn test_use_name_with_auth(#[values("tests/requests/auth.rs")] file: &str) { - let generator = super::auth::run_generator(true); + let generator = super::auth::run_generator(true, DBOption::None); let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap index fbfcb3e2f..db70c3d1f 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/asset.rs +source: tests/templates/asset.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", unic-langid = "0.9.4", fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, unic-langid = { version = "0.9.4" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap index 567b40792..29bf8d6cc 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/asset.rs +source: tests/templates/asset.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap index fbfcb3e2f..db70c3d1f 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/asset.rs +source: tests/templates/asset.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", unic-langid = "0.9.4", fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, unic-langid = { version = "0.9.4" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_None.snap similarity index 98% rename from loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap rename to loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_None.snap index bc2274ca4..692d668ff 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_None.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap new file mode 100644 index 000000000..908d93f36 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap @@ -0,0 +1,69 @@ +--- +source: tests/templates/auth.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use std::path::Path; +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::DatabaseConnection; + +#[allow(unused_imports)] +use crate::{ + controllers ,tasks +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + // tasks-inject (do not remove) + } + async fn truncate(_db: &DatabaseConnection) -> Result<()> { + Ok(()) + } + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { + Ok(()) + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_None.snap similarity index 94% rename from loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap rename to loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_None.snap index a98e1f422..1059932aa 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_None.snap @@ -9,6 +9,7 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, + db::{self, truncate_table}, environment::Environment, task::Tasks, Result, @@ -16,7 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks + , models::_entities::users }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap new file mode 100644 index 000000000..84e421a78 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap @@ -0,0 +1,73 @@ +--- +source: tests/templates/auth.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use std::path::Path; +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + db::{self, truncate_table}, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::DatabaseConnection; + +#[allow(unused_imports)] +use crate::{ + controllers ,tasks + , models::_entities::users +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::auth::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + // tasks-inject (do not remove) + } + async fn truncate(db: &DatabaseConnection) -> Result<()> { + truncate_table(db, users::Entity).await?; + Ok(()) + } + async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + Ok(()) + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap index cf70e6fdc..cad57e737 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap @@ -17,8 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers - , workers::downloader::DownloadWorker, + controllers ,tasks + , workers::downloader::DownloadWorker }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap index cf70e6fdc..cad57e737 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap @@ -17,8 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers - , workers::downloader::DownloadWorker, + controllers ,tasks + , workers::downloader::DownloadWorker }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap index 16e6e5ccc..8d1799bd0 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap index cf70e6fdc..cad57e737 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap @@ -17,8 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers - , workers::downloader::DownloadWorker, + controllers ,tasks + , workers::downloader::DownloadWorker }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap index 9f6247be3..023a52c70 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/db.rs +source: tests/templates/db.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap index 859cb0fdf..385489b5e 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/db.rs +source: tests/templates/db.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", chrono = "0.4", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, migration = { path = "migration" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, chrono = { version = "0.4" }, loco-rs = { workspace = true }, migration = { path = "migration" }, regex = { version = "1.11.1" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap index 859cb0fdf..385489b5e 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/db.rs +source: tests/templates/db.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", chrono = "0.4", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, migration = { path = "migration" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, chrono = { version = "0.4" }, loco-rs = { workspace = true }, migration = { path = "migration" }, regex = { version = "1.11.1" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap index 2f3556aa9..b09ac84eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap index 41925c044..c9393b9eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap @@ -10,7 +10,6 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, - db::{self, truncate_table}, environment::Environment, task::Tasks, Result, @@ -20,9 +19,7 @@ use sea_orm::DatabaseConnection; #[allow(unused_imports)] use crate::{ - controllers - ,tasks - , models::_entities::users, + controllers ,tasks }; pub struct App; @@ -63,13 +60,10 @@ impl Hooks for App { fn register_tasks(tasks: &mut Tasks) { // tasks-inject (do not remove) } - async fn truncate(db: &DatabaseConnection) -> Result<()> { - truncate_table(db, users::Entity).await?; + async fn truncate(_db: &DatabaseConnection) -> Result<()> { Ok(()) - } - - async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { - db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + } + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { Ok(()) } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap index 41925c044..c9393b9eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap @@ -10,7 +10,6 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, - db::{self, truncate_table}, environment::Environment, task::Tasks, Result, @@ -20,9 +19,7 @@ use sea_orm::DatabaseConnection; #[allow(unused_imports)] use crate::{ - controllers - ,tasks - , models::_entities::users, + controllers ,tasks }; pub struct App; @@ -63,13 +60,10 @@ impl Hooks for App { fn register_tasks(tasks: &mut Tasks) { // tasks-inject (do not remove) } - async fn truncate(db: &DatabaseConnection) -> Result<()> { - truncate_table(db, users::Entity).await?; + async fn truncate(_db: &DatabaseConnection) -> Result<()> { Ok(()) - } - - async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { - db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + } + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { Ok(()) } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap index 6dcd176ac..3199b528d 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap index 7bbc9d0e1..4691632a0 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, initializers, + controllers ,tasks, initializers }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap index 650bcff34..5a2cce91d 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/mailer.rs +source: tests/templates/mailer.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap index 5e1a0f8d3..7d0bba4b7 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/mailer.rs +source: tests/templates/mailer.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", include_dir = "0.7", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, include_dir = { version = "0.7" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/wizard/new.rs b/loco-new/tests/wizard/new.rs index 26f60a902..f23cd80e7 100644 --- a/loco-new/tests/wizard/new.rs +++ b/loco-new/tests/wizard/new.rs @@ -78,6 +78,13 @@ fn test_starter_combinations() { AssetsOption::Clientside, true, ); + // test only DB + test_combination( + DBOption::Sqlite, + BackgroundOption::None, + AssetsOption::None, + true, + ); } fn test_combination( diff --git a/src/bgworker/mod.rs b/src/bgworker/mod.rs index be6b43b92..1fe9ba0a9 100644 --- a/src/bgworker/mod.rs +++ b/src/bgworker/mod.rs @@ -313,13 +313,13 @@ impl Queue { age_days: Option, ) -> Result { tracing::debug!(status = ?status, age_days = ?age_days, "getting jobs"); - let jobs = match self { + match self { #[cfg(feature = "bg_pg")] Self::Postgres(pool, _, _) => { let jobs = pg::get_jobs(pool, status, age_days) .await .map_err(Box::from)?; - serde_json::to_value(jobs)? + Ok(serde_json::to_value(jobs)?) } #[cfg(feature = "bg_sqlt")] Self::Sqlite(pool, _, _) => { @@ -327,25 +327,23 @@ impl Queue { .await .map_err(Box::from)?; - serde_json::to_value(jobs)? + Ok(serde_json::to_value(jobs)?) } #[cfg(feature = "bg_redis")] Self::Redis(_, _, _) => { tracing::error!("getting jobs for redis provider not implemented"); - return Err(Error::string( + Err(Error::string( "getting jobs not supported for redis provider", - )); + )) } Self::None => { tracing::error!( "no queue provider is configured: compile with at least one queue provider \ feature" ); - return Err(Error::string("provider not configure")); + Err(Error::string("provider not configure")) } - }; - - Ok(jobs) + } } /// Cancels jobs based on the given job name for the configured queue provider. diff --git a/src/cli.rs b/src/cli.rs index a179bc7c1..8f88f8455 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,12 +20,21 @@ cfg_if::cfg_if! { use crate::doctor; use crate::boot::{run_db}; use crate::db; - use std::process::exit; } else {} } +#[cfg(any( + feature = "bg_redis", + feature = "bg_pg", + feature = "bg_sqlt", + feature = "with-db" +))] +use std::process::exit; + use std::{collections::BTreeMap, path::PathBuf}; +#[cfg(any(feature = "bg_redis", feature = "bg_pg", feature = "bg_sqlt"))] +use crate::bgworker::JobStatus; use clap::{ArgAction, Parser, Subcommand}; use colored::Colorize; use duct::cmd; @@ -33,7 +42,6 @@ use loco_gen::{Component, ScaffoldKind}; use crate::{ app::{AppContext, Hooks}, - bgworker::JobStatus, boot::{ create_app, create_context, list_endpoints, list_middlewares, run_scheduler, run_task, start, RunDbCommand, ServeParams, StartMode, diff --git a/src/env_vars.rs b/src/env_vars.rs index 83a6e8587..aaf111cee 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -3,6 +3,7 @@ //! fetching environment variables, ensuring that keys are easily accessible //! from a single location in the codebase. +#[cfg(feature = "with-db")] /// The key for `PostgreSQL` database options environment variable. pub const POSTGRES_DB_OPTIONS: &str = "LOCO_POSTGRES_DB_OPTIONS"; /// The key for the application's environment (e.g., development, production). @@ -19,6 +20,7 @@ pub fn get(key: &str) -> Result { std::env::var(key) } +#[allow(dead_code)] /// Retrieves the value of the given environment variable, or returns a default value if the variable is not set. pub fn get_or_default(key: &str, default: &str) -> String { get(key).unwrap_or_else(|_| default.to_string()) diff --git a/src/hash.rs b/src/hash.rs index d8a96c2b3..84236112c 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -1,9 +1,9 @@ +use crate::{Error, Result}; use argon2::{ password_hash::SaltString, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, }; - -use crate::{Error, Result}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; /// Hashes a plain text password and returns the hashed result. /// @@ -57,6 +57,26 @@ pub fn verify_password(pass: &str, hashed_password: &str) -> bool { arg2.verify_password(pass.as_bytes(), &hash).is_ok() } +/// Generates a random alphanumeric string of the specified length. +/// +/// # Example +/// +/// ```rust +/// use loco_rs::hash; +/// +/// let rand_str = hash::random_string(10); +/// assert_eq!(rand_str.len(), 10); +/// assert_ne!(rand_str, hash::random_string(10)); +/// +/// ``` +pub fn random_string(length: usize) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} + #[cfg(test)] mod tests { @@ -70,4 +90,14 @@ mod tests { assert!(verify_password(pass, &hash_pass)); } + + #[test] + fn can_random_string() { + let random_length = 32; + let first = random_string(random_length); + assert_eq!(first.len(), random_length); + let second: String = random_string(random_length); + assert_eq!(second.len(), random_length); + assert_ne!(first, second); + } } diff --git a/src/prelude.rs b/src/prelude.rs index 03ae9cf8c..47353fd17 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -27,7 +27,7 @@ pub use crate::{ app::{AppContext, Initializer}, bgworker::{BackgroundWorker, Queue}, controller::{ - format, + bad_request, format, middleware::{ format::{Format, RespondTo}, remote_ip::RemoteIP,