Initial commit: Book management system with Rust Loco backend and Vue 3 frontend
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
mod models;
|
||||
mod requests;
|
||||
mod tasks;
|
||||
mod workers;
|
||||
@@ -0,0 +1 @@
|
||||
mod users;
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: res
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@framework.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "framework",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: existing_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||
id: 1,
|
||||
pid: 11111111-1111-1111-1111-111111111111,
|
||||
email: "user1@example.com",
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||
name: "user1",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: existing_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||
id: 1,
|
||||
pid: 11111111-1111-1111-1111-111111111111,
|
||||
email: "user1@example.com",
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||
name: "user1",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: res
|
||||
---
|
||||
Err(
|
||||
Custom(
|
||||
"{\"email\":[{\"code\":\"email\",\"message\":\"invalid email\"}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: new_user
|
||||
---
|
||||
Err(
|
||||
EntityAlreadyExists,
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
use book_manager::{
|
||||
app::App,
|
||||
models::users::{self, Model, RegisterParams},
|
||||
};
|
||||
use chrono::{offset::Local, Duration};
|
||||
use insta::assert_debug_snapshot;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||
use serial_test::serial;
|
||||
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.set_snapshot_suffix("users");
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_can_validate_model() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
|
||||
let invalid_user = users::ActiveModel {
|
||||
name: ActiveValue::set("1".to_string()),
|
||||
email: ActiveValue::set("invalid-email".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let res = invalid_user.insert(&boot.app_context.db).await;
|
||||
|
||||
assert_debug_snapshot!(res);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_create_with_password() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.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!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(res);
|
||||
});
|
||||
}
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn handle_create_with_password_with_duplicate() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let new_user = Model::create_with_password(
|
||||
&boot.app_context.db,
|
||||
&RegisterParams {
|
||||
email: "user1@example.com".to_string(),
|
||||
password: "1234".to_string(),
|
||||
name: "framework".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_debug_snapshot!(new_user);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_find_by_email() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.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;
|
||||
|
||||
assert_debug_snapshot!(existing_user);
|
||||
assert_debug_snapshot!(non_existing_user_results);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_find_by_pid() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.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;
|
||||
|
||||
assert_debug_snapshot!(existing_user);
|
||||
assert_debug_snapshot!(non_existing_user_results);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_verification_token() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
let result = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&boot.app_context.db)
|
||||
.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
|
||||
.expect("Failed to find user by PID after setting verification sent");
|
||||
|
||||
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::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.reset_sent_at.is_none(),
|
||||
"Expected no reset sent timestamp"
|
||||
);
|
||||
assert!(user.reset_token.is_none(), "Expected no reset token");
|
||||
|
||||
let result = user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&boot.app_context.db)
|
||||
.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
|
||||
.expect("Failed to find user by PID after setting forgot password sent");
|
||||
|
||||
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::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.email_verified_at.is_none(),
|
||||
"Expected email to be unverified"
|
||||
);
|
||||
|
||||
let result = user
|
||||
.into_active_model()
|
||||
.verified(&boot.app_context.db)
|
||||
.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
|
||||
.expect("Failed to find user by PID after verification");
|
||||
|
||||
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::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.verify_password("12341234"),
|
||||
"Password verification failed for original password"
|
||||
);
|
||||
|
||||
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
|
||||
.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::<App>().await.unwrap();
|
||||
seed::<App>(&boot.app_context).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
|
||||
.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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
use book_manager::{app::App, models::users};
|
||||
use insta::{assert_debug_snapshot, with_settings};
|
||||
use loco_rs::testing::prelude::*;
|
||||
use rstest::rstest;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::prepare_data;
|
||||
|
||||
// TODO: see how to dedup / extract this to app-local test utils
|
||||
// not to framework, because that would require a runtime dep on insta
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.set_snapshot_suffix("auth_request");
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_register() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
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!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(saved_user);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("login_with_valid_password", "12341234")]
|
||||
#[case("login_with_invalid_password", "invalid-password")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
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 email_verification_token = user
|
||||
.email_verification_token
|
||||
.expect("Email verification token should be generated");
|
||||
request
|
||||
.get(&format!("/api/auth/verify/{email_verification_token}"))
|
||||
.await;
|
||||
|
||||
//verify user request
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": password
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Make sure email_verified_at is set
|
||||
let user = users::Model::find_by_email(&ctx.db, email)
|
||||
.await
|
||||
.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()
|
||||
}, {
|
||||
assert_debug_snapshot!(test_name, (response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn login_with_un_existing_email() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
|
||||
let login_response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": "un_existing@loco.rs",
|
||||
"password": "1234"
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(login_response.status_code(), 401, "Login request should return 401");
|
||||
login_response.assert_json(&serde_json::json!({"error": "unauthorized", "description": "You do not have permission to access this resource"}));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_login_without_verify() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let password = "12341234";
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
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 login_response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": password
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
login_response.status_code(),
|
||||
200,
|
||||
"Login request should succeed"
|
||||
);
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(login_response.text());
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn invalid_verification_token() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
let response = request.get("/api/auth/verify/invalid-token").await;
|
||||
|
||||
assert_eq!(response.status_code(), 401, "Verify request should reject");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reset_password() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let login_data = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let forgot_payload = serde_json::json!({
|
||||
"email": login_data.user.email,
|
||||
});
|
||||
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
|
||||
.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!({
|
||||
"token": user.reset_token,
|
||||
"password": new_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
|
||||
.unwrap();
|
||||
|
||||
assert!(user.reset_token.is_none());
|
||||
assert!(user.reset_sent_at.is_none());
|
||||
|
||||
assert_debug_snapshot!(reset_response.text());
|
||||
|
||||
let login_response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": user.email,
|
||||
"password": new_password
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
login_response.status_code(),
|
||||
200,
|
||||
"Login request should succeed"
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_get_current_user() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let user = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
|
||||
let response = request
|
||||
.get("/api/auth/current")
|
||||
.add_header(auth_key, auth_value)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Current request should succeed"
|
||||
);
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!((response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_auth_with_magic_link() {
|
||||
configure_insta!();
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
seed::<App>(&ctx).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]{{{}}}", 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::<App, _, _>(|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::<App, _, _>(|request, ctx| async move {
|
||||
seed::<App>(&ctx).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;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_resend_verification_email() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
let response = request.post("/api/auth/register").json(&payload).await;
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Register request should succeed"
|
||||
);
|
||||
|
||||
let resend_payload = serde_json::json!({ "email": email });
|
||||
|
||||
let resend_response = request
|
||||
.post("/api/auth/resend-verification-mail")
|
||||
.json(&resend_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resend_response.status_code(),
|
||||
200,
|
||||
"Resend verification email should succeed"
|
||||
);
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
|
||||
assert_eq!(
|
||||
deliveries.count, 2,
|
||||
"Two emails should have been sent: welcome and re-verification"
|
||||
);
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, email)
|
||||
.await
|
||||
.expect("User should exist");
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!("resend_verification_user", user);
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cannot_resend_email_if_already_verified() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "verified@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "verified",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
request.post("/api/auth/register").json(&payload).await;
|
||||
|
||||
// Verify user
|
||||
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
|
||||
if let Some(token) = user.email_verification_token.clone() {
|
||||
request.get(&format!("/api/auth/verify/{token}")).await;
|
||||
}
|
||||
|
||||
// Try resending verification email
|
||||
let resend_payload = serde_json::json!({ "email": email });
|
||||
|
||||
let resend_response = request
|
||||
.post("/api/auth/resend-verification-mail")
|
||||
.json(&resend_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resend_response.status_code(),
|
||||
200,
|
||||
"Should return 200 even if already verified"
|
||||
);
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
assert_eq!(
|
||||
deliveries.count, 1,
|
||||
"Only the original welcome email should be sent"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use book_manager::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::prepare_data;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_list_books_with_pagination() {
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
// First test if auth endpoint works (to verify routing is working)
|
||||
let auth_response = request.get("/api/auth/current").await;
|
||||
println!("Auth endpoint status: {}", auth_response.status_code());
|
||||
|
||||
// Now test books endpoint
|
||||
let response = request.get("/api/books").await;
|
||||
|
||||
let status = response.status_code();
|
||||
let body = response.text();
|
||||
|
||||
println!("Books endpoint status: {}", status);
|
||||
println!("Books response (first 300 chars): {}", &body[..body.len().min(300)]);
|
||||
|
||||
// The route should return 200 with JSON, not HTML
|
||||
assert_eq!(
|
||||
status,
|
||||
200,
|
||||
"List books request should succeed"
|
||||
);
|
||||
|
||||
// Check if it's JSON (not HTML)
|
||||
assert!(
|
||||
body.trim_start().starts_with('{'),
|
||||
"Response should be JSON, but got: {}",
|
||||
&body[..body.len().min(100)]
|
||||
);
|
||||
|
||||
// Try to parse as JSON
|
||||
let json: serde_json::Value = serde_json::from_str(&body)
|
||||
.expect("Response should be valid JSON");
|
||||
|
||||
// Verify response structure
|
||||
assert!(json.get("books").is_some(), "Response should contain 'books' field");
|
||||
assert!(json.get("total").is_some(), "Response should contain 'total' field");
|
||||
assert!(json.get("page").is_some(), "Response should contain 'page' field");
|
||||
assert!(json.get("per_page").is_some(), "Response should contain 'per_page' field");
|
||||
assert!(json.get("total_pages").is_some(), "Response should contain 'total_pages' field");
|
||||
|
||||
// Verify default values
|
||||
assert_eq!(json["page"], 1, "Default page should be 1");
|
||||
assert_eq!(json["per_page"], 12, "Default per_page should be 12");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_list_books_with_custom_pagination() {
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
// Test custom pagination parameters
|
||||
let response = request.get("/api/books?page=2&per_page=5").await;
|
||||
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"List books with custom pagination should succeed"
|
||||
);
|
||||
|
||||
let json: serde_json::Value = response.json();
|
||||
|
||||
assert_eq!(json["page"], 2, "Page should be 2");
|
||||
assert_eq!(json["per_page"], 5, "Per page should be 5");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_get_single_book() {
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
// First create a test book
|
||||
let user = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let book_payload = serde_json::json!({
|
||||
"title": "Test Book",
|
||||
"author": "Test Author",
|
||||
"description": "A test book description",
|
||||
"content": "This is the content of the test book."
|
||||
});
|
||||
|
||||
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
|
||||
|
||||
let create_response = request
|
||||
.post("/api/books")
|
||||
.add_header(auth_key, auth_value.clone())
|
||||
.json(&book_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
create_response.status_code(),
|
||||
200,
|
||||
"Create book request should succeed"
|
||||
);
|
||||
|
||||
let created_book: serde_json::Value = create_response.json();
|
||||
let book_id = created_book["id"].as_i64().unwrap();
|
||||
|
||||
// Now get the book by ID
|
||||
let get_response = request.get(&format!("/api/books/{}", book_id)).await;
|
||||
|
||||
assert_eq!(
|
||||
get_response.status_code(),
|
||||
200,
|
||||
"Get single book request should succeed"
|
||||
);
|
||||
|
||||
let retrieved_book: serde_json::Value = get_response.json();
|
||||
|
||||
assert_eq!(retrieved_book["title"], "Test Book");
|
||||
assert_eq!(retrieved_book["author"], "Test Author");
|
||||
assert_eq!(retrieved_book["description"], "A test book description");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_create_book_with_auth() {
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let user = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let book_payload = serde_json::json!({
|
||||
"title": "Rust Programming",
|
||||
"author": "Steve Klabnik",
|
||||
"description": "Learn Rust programming",
|
||||
"content": "Rust is a systems programming language..."
|
||||
});
|
||||
|
||||
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
|
||||
|
||||
let response = request
|
||||
.post("/api/books")
|
||||
.add_header(auth_key, auth_value)
|
||||
.json(&book_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Create book request should succeed with authentication"
|
||||
);
|
||||
|
||||
let json: serde_json::Value = response.json();
|
||||
|
||||
assert_eq!(json["title"], "Rust Programming");
|
||||
assert_eq!(json["author"], "Steve Klabnik");
|
||||
assert!(json.get("id").is_some(), "Created book should have an ID");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cannot_create_book_without_auth() {
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
let book_payload = serde_json::json!({
|
||||
"title": "Unauthorized Book",
|
||||
"author": "No Auth",
|
||||
"content": "This should fail"
|
||||
});
|
||||
|
||||
let response = request
|
||||
.post("/api/books")
|
||||
.json(&book_payload)
|
||||
.await;
|
||||
|
||||
// Should return 401 Unauthorized or similar error
|
||||
assert_ne!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Create book should fail without authentication"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use book_manager::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_books_route_exists() {
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
// Try to access books test endpoint
|
||||
let response = request.get("/api/books/test").await;
|
||||
|
||||
println!("Status: {}", response.status_code());
|
||||
println!("Body: {}", response.text());
|
||||
|
||||
// Check if we get JSON response
|
||||
assert_eq!(response.status_code(), 200);
|
||||
let body = response.text();
|
||||
assert!(body.contains("Books controller is working"), "Got: {}", body);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod auth;
|
||||
mod books;
|
||||
mod books_debug;
|
||||
mod prepare_data;
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use book_manager::{models::users, views::auth::LoginResponse};
|
||||
use loco_rs::{app::AppContext, TestServer};
|
||||
|
||||
const USER_EMAIL: &str = "test@loco.com";
|
||||
const USER_PASSWORD: &str = "1234";
|
||||
|
||||
pub struct LoggedInUser {
|
||||
pub user: users::Model,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser {
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": USER_EMAIL,
|
||||
"password": USER_PASSWORD
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
let user = users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let verify_payload = serde_json::json!({
|
||||
"token": user.email_verification_token,
|
||||
});
|
||||
|
||||
request.post("/api/auth/verify").json(&verify_payload).await;
|
||||
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": USER_EMAIL,
|
||||
"password": USER_PASSWORD
|
||||
}))
|
||||
.await;
|
||||
|
||||
let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap();
|
||||
|
||||
LoggedInUser {
|
||||
user: users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||
.await
|
||||
.unwrap(),
|
||||
token: login_response.token,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) {
|
||||
let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap();
|
||||
|
||||
(HeaderName::from_static("authorization"), auth_header_value)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: magic_link_response.text()
|
||||
---
|
||||
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}"
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(response.status_code(), response.text())"
|
||||
---
|
||||
(
|
||||
200,
|
||||
"{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: login_response.text()
|
||||
---
|
||||
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}"
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: saved_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@loco.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "loco",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: Some(
|
||||
"PID",
|
||||
),
|
||||
email_verification_sent_at: Some(
|
||||
DATE,
|
||||
),
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(reset_response.status_code(), reset_response.text())"
|
||||
---
|
||||
"null"
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(response.status_code(), response.text())"
|
||||
---
|
||||
(
|
||||
401,
|
||||
"{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}",
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(response.status_code(), response.text())"
|
||||
---
|
||||
(
|
||||
200,
|
||||
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}",
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
assertion_line: 414
|
||||
expression: user
|
||||
---
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@loco.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "loco",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: Some(
|
||||
"PID",
|
||||
),
|
||||
email_verification_sent_at: Some(
|
||||
DATE,
|
||||
),
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user