diff --git a/nix/shells/rust.nix b/nix/shells/rust.nix index 79e2dd9..349c46b 100644 --- a/nix/shells/rust.nix +++ b/nix/shells/rust.nix @@ -8,6 +8,7 @@ shell = { mkShell, bacon, + cargo-nextest, cargo-workspaces, diesel-cli, postgresql_18, @@ -24,6 +25,7 @@ packages = [ bacon + cargo-nextest cargo-workspaces diesel-cli postgresql_18.lib diff --git a/rust/henka-api/src/errors.rs b/rust/henka-api/src/errors.rs index f1e3402..fe235f3 100644 --- a/rust/henka-api/src/errors.rs +++ b/rust/henka-api/src/errors.rs @@ -17,3 +17,39 @@ pub(crate) enum AccessDeniedError { #[error("no user session")] NoUserSession, } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::forbidden( + AccountCreationError::ForbiddenWhenLoggedIn, + "account creation forbidden when logged in" + )] + #[case::confirmations( + AccountCreationError::ConfirmationsDontMatch, + "email and password confirmations do not match" + )] + #[case::email( + AccountCreationError::EmailConfirmationDoesntMatch, + "email confirmation does not match" + )] + #[case::password( + AccountCreationError::PasswordConfirmationDoesntMatch, + "password confirmation does not match" + )] + fn account_creation_error_messages( + #[case] error: AccountCreationError, + #[case] expected: &str, + ) { + assert_eq!(error.to_string(), expected); + } + + #[rstest] + #[case::no_session(AccessDeniedError::NoUserSession, "no user session")] + fn access_denied_error_message(#[case] error: AccessDeniedError, #[case] expected: &str) { + assert_eq!(error.to_string(), expected); + } +} diff --git a/rust/henka-api/src/schema.rs b/rust/henka-api/src/schema.rs index 83dc3b7..bc41de9 100644 --- a/rust/henka-api/src/schema.rs +++ b/rust/henka-api/src/schema.rs @@ -113,3 +113,133 @@ impl Mutations { } pub(crate) type Schema = RootNode; + +#[cfg(test)] +mod tests { + use rstest::rstest; + use std::sync::{Arc, Mutex}; + + use deadpool_diesel::{Manager, Pool, Runtime}; + use uuid::Uuid; + + use crate::context::Context; + use crate::model::user::User; + + use super::*; + + fn test_db_url() -> String { + std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set for unit tests") + } + + /// Creates a `Context` for unit testing. The pool is never used by resolvers. + fn test_context(user: Option) -> Context { + let manager = Manager::new(test_db_url(), Runtime::Tokio1); + let pool = Pool::builder(manager) + .max_size(1) + .build() + .expect("failed to create test pool") + .into(); + Context { + current_user: Arc::new(Mutex::new(user)), + pool, + } + } + + fn sample_user() -> User { + User { + id: Uuid::new_v4(), + nick_name: "testuser".to_string(), + password_hash: String::new(), + } + } + + #[rstest] + #[case(2, 3, 5)] + #[case(-1, 1, 0)] + #[case(0, 0, 0)] + fn add_returns_sum(#[case] a: i32, #[case] b: i32, #[case] expected: i32) { + assert_eq!(Query::add(a, b), expected); + } + + #[tokio::test] + async fn mul_requires_auth() { + let ctx = test_context(None); + let result = Query::mul(&ctx, 3, 4); + result.unwrap_err(); + } + + #[tokio::test] + async fn mul_with_auth_succeeds() { + let ctx = test_context(Some(sample_user())); + let result = Query::mul(&ctx, 3, 4); + assert_eq!(result.unwrap(), Some(12)); + } + + #[tokio::test] + async fn me_without_auth_returns_error() { + let ctx = test_context(None); + let result = Query::me(&ctx); + result.unwrap_err(); + } + + #[tokio::test] + async fn me_with_auth_returns_user() { + let user = sample_user(); + let ctx = test_context(Some(user.clone())); + let result = Query::me(&ctx).unwrap().unwrap(); + assert_eq!(result.id, user.id); + assert_eq!(result.name, user.nick_name); + } + + #[tokio::test] + async fn register_user_succeeds() { + let ctx = test_context(None); + let result = Mutations::register_user( + &ctx, + "newuser".into(), + "user@example.com".into(), + "user@example.com".into(), + "pass".into(), + "pass".into(), + ); + let me = result.unwrap(); + assert_eq!(me.name, "newuser"); + } + + #[tokio::test] + async fn register_user_forbidden_when_logged_in() { + let ctx = test_context(Some(sample_user())); + let result = Mutations::register_user( + &ctx, + "newuser".into(), + "user@example.com".into(), + "user@example.com".into(), + "pass".into(), + "pass".into(), + ); + result.unwrap_err(); + } + + #[rstest] + #[case::email_mismatch("user@example.com", "different@example.com", "pass", "pass")] + #[case::password_mismatch("user@example.com", "user@example.com", "pass", "different")] + #[case::both_mismatch("user@example.com", "other@example.com", "pass", "different")] + #[tokio::test] + async fn register_user_validation_fails( + #[case] email: &str, + #[case] email_confirm: &str, + #[case] password: &str, + #[case] password_confirm: &str, + ) { + let ctx = test_context(None); + let result = Mutations::register_user( + &ctx, + "newuser".into(), + email.into(), + email_confirm.into(), + password.into(), + password_confirm.into(), + ); + result.unwrap_err(); + } +} diff --git a/rust/henka-api/tests/graphql.rs b/rust/henka-api/tests/graphql.rs index c3ec674..02f278c 100644 --- a/rust/henka-api/tests/graphql.rs +++ b/rust/henka-api/tests/graphql.rs @@ -126,3 +126,133 @@ async fn query(app: Router, q: &str, token: Option<&str>) -> (StatusCode, Value) let body: Value = serde_json::from_slice(&bytes).unwrap(); (status, body) } + +// ---- me query integration tests ---- + +#[rstest] +#[tokio::test] +async fn test_me_without_auth_returns_error() { + let (status, body) = query(build_router(&*DB_URL), "{ me { id name } }", None).await; + assert_eq!(status, StatusCode::OK); + assert!( + body.get("errors").is_some(), + "expected GraphQL error for unauthenticated me query" + ); + assert!(body["data"]["me"].is_null()); +} + +#[rstest] +#[tokio::test] +async fn test_me_with_valid_token(_db: (), user: User) { + let (status, body) = query( + build_router(&*DB_URL), + "{ me { id name } }", + Some(&user.id.to_string()), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["me"]["name"], "testuser"); + assert_eq!(body["data"]["me"]["id"], user.id.to_string()); +} + +#[rstest] +#[tokio::test] +async fn test_me_with_wrong_token_returns_error(_db: ()) { + let wrong_token = uuid::Uuid::new_v4(); + let (status, body) = query( + build_router(&*DB_URL), + "{ me { id name } }", + Some(&wrong_token.to_string()), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + body.get("errors").is_some(), + "expected GraphQL error for wrong token on me query" + ); + assert!(body["data"]["me"].is_null()); +} + +// ---- registerUser mutation integration tests ---- + +const REGISTER_MUTATION_SUCCESS: &str = r#"mutation { registerUser(name: "newuser", email: "user@example.com", emailConfirm: "user@example.com", password: "pass", passwordConfirm: "pass") { id name } }"#; + +#[rstest] +#[tokio::test] +async fn test_register_user_succeeds() { + let (status, body) = query(build_router(&*DB_URL), REGISTER_MUTATION_SUCCESS, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["registerUser"]["name"], "newuser"); + assert!(body["data"]["registerUser"]["id"].as_str().is_some()); +} + +#[rstest] +#[case::email_mismatch( + r#"mutation { registerUser(name: "newuser", email: "user@example.com", emailConfirm: "different@example.com", password: "pass", passwordConfirm: "pass") { id name } }"#, + "email confirmation does not match" +)] +#[case::password_mismatch( + r#"mutation { registerUser(name: "newuser", email: "user@example.com", emailConfirm: "user@example.com", password: "pass", passwordConfirm: "different") { id name } }"#, + "password confirmation does not match" +)] +#[case::both_mismatch( + r#"mutation { registerUser(name: "newuser", email: "user@example.com", emailConfirm: "other@example.com", password: "pass", passwordConfirm: "different") { id name } }"#, + "email and password confirmations do not match" +)] +#[tokio::test] +async fn test_register_user_validation_fails( + #[case] mutation: &str, + #[case] expected_message: &str, +) { + let (status, body) = query(build_router(&*DB_URL), mutation, None).await; + assert_eq!(status, StatusCode::OK); + assert!(body.get("errors").is_some()); + assert_eq!(body["errors"][0]["message"], expected_message); +} + +#[rstest] +#[tokio::test] +async fn test_register_user_forbidden_when_logged_in(_db: (), user: User) { + let (status, body) = query( + build_router(&*DB_URL), + REGISTER_MUTATION_SUCCESS, + Some(&user.id.to_string()), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body.get("errors").is_some()); + assert_eq!( + body["errors"][0]["message"], + "account creation forbidden when logged in" + ); +} + +// ---- Non-GraphQL endpoint tests ---- + +#[expect(clippy::unwrap_used, reason = "unwrap is fine here, test only")] +async fn get_body(app: Router, path: &str) -> (StatusCode, String) { + let req = axum::http::Request::builder() + .method("GET") + .uri(path) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + let status = res.status(); + let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let text = String::from_utf8(bytes.to_vec()).unwrap(); + (status, text) +} + +#[rstest] +#[case::homepage("/", "juniper_axum/simple example")] +#[case::graphiql("/graphiql", "GraphiQL")] +#[case::playground("/playground", "GraphQL Playground")] +#[tokio::test] +async fn test_non_graphql_endpoints(#[case] path: &str, #[case] expected_content: &str) { + let (status, body) = get_body(build_router(&*DB_URL), path).await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains(expected_content), + "expected body to contain '{expected_content}'" + ); +}