diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 0f0fc05..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.env.dev b/.env.dev index e54bc46..fbd7247 100644 --- a/.env.dev +++ b/.env.dev @@ -4,6 +4,7 @@ export DB_HOSTNAME="localhost" export DB_PORT="3306" export DB_DATABASE="world" export RUST_LOG="debug" +export DATABASE_URL="mariadb://$DB_USERNAME:$DB_PASSWORD@$DB_HOSTNAME:$DB_PORT/$DB_DATABASE" export MAIL_ADDRESS="test@gmail.com" export MAIL_PASSWORD="**** **** **** ****" diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index b0ef64e..39120ca 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -14,6 +14,16 @@ env: jobs: test: runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:latest + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: world + TZ: Asia/Tokyo + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,7 +42,7 @@ jobs: - name: Build test run: cargo build --release --verbose - name: Run test - run: cargo test --verbose + run: source .env.dev && cargo test --verbose - name: Lint with clippy run: cargo clippy --all-targets --all-features - name: Check formatting diff --git a/Cargo.toml b/Cargo.toml index c7b3618..25c6c97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,12 @@ edition = "2021" [dependencies] axum = "0.7" +http-body-util = "0.1.0" axum-extra = { version = "0.9", features = [ "typed-header" ] } tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" -tower = "0.4" +tower = { version = "0.4", features = ["full"] } tower-http = { version = "0.5.0", features = ["add-extension", "trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/handler/users.rs b/src/handler/users.rs index b50ac34..0cb40a9 100644 --- a/src/handler/users.rs +++ b/src/handler/users.rs @@ -178,14 +178,18 @@ pub async fn put_me( self_introduction: body.self_introduction.unwrap_or(user.self_introduction), }; - let icon_url = new_body.icon_url.clone(); - state .update_user(user.display_id, new_body) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(serde_json::json!({"iconUrl": icon_url}))) + let new_user = state + .get_user_by_display_id(display_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(new_user)) } pub async fn get_user( diff --git a/src/repository.rs b/src/repository.rs index 5d90f1a..9f515ed 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -34,6 +34,18 @@ impl Repository { Ok(()) } + + pub async fn create_by_pool(pool: sqlx::MySqlPool) -> anyhow::Result { + let session_store = + MySqlSessionStore::from_client(pool.clone()).with_table_name("user_sessions"); + session_store.migrate().await?; + + Ok(Self { + pool, + session_store, + bcrypt_cost: bcrypt::DEFAULT_COST, + }) + } } fn get_option_from_env() -> anyhow::Result { diff --git a/src/repository/users.rs b/src/repository/users.rs index 72b57ec..dc12e5a 100644 --- a/src/repository/users.rs +++ b/src/repository/users.rs @@ -12,7 +12,9 @@ use super::Repository; #[derive(Debug, Clone, PartialEq, sqlx::Type, Serialize)] #[repr(i32)] pub enum UserRole { + #[serde(rename = "commonUser")] common_user = 0, + #[serde(rename = "traPUser")] traP_user = 1, admin = 2, } diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..5975988 --- /dev/null +++ b/tests/test.rs @@ -0,0 +1 @@ +mod users; diff --git a/tests/users/common/check.rs b/tests/users/common/check.rs new file mode 100644 index 0000000..b2caa6f --- /dev/null +++ b/tests/users/common/check.rs @@ -0,0 +1,50 @@ +use super::remove_field; +use serde_json::{json, Value}; + +pub fn users_check_by_id(id: i64, resp_json: &mut Value) -> anyhow::Result<()> { + let users_json = match id { + 1 => json!({ + "id": "11111111-1111-1111-1111-111111111111", + "displayId": 1, + "name": "test_user_1", + "traqId": null, + "githubId": "test_github_id_1", + "iconUrl": null, + "githubLink": "https://github.com/test_user_1", + "xLink": null, + "selfIntroduction": "test_self_introduction_1", + "role": "commonUser", + }), + 2 => json!({ + "id": "22222222-2222-2222-2222-222222222222", + "displayId": 2, + "name": "test_user_2", + "traqId": "test_traq_id_2", + "githubId": null, + "iconUrl": null, + "githubLink": null, + "xLink": "https://x.com/test_user_2", + "selfIntroduction": "", + "role": "traPUser", + }), + 3 => json!({ + "id": "33333333-3333-3333-3333-333333333333", + "displayId": 3, + "name": "test_user_3", + "traqId": null, + "githubId": null, + "iconUrl": "https://icon.com/test_user_3", + "githubLink": null, + "xLink": null, + "selfIntroduction": "", + "role": "admin", + }), + _ => return Err(anyhow::anyhow!("Invalid id")), + }; + let ignore_field = vec!["createdAt", "updatedAt"]; + + remove_field(resp_json, &ignore_field); + assert_eq!(resp_json, &users_json); + + Ok(()) +} diff --git a/tests/users/common/mod.rs b/tests/users/common/mod.rs new file mode 100644 index 0000000..ee044ba --- /dev/null +++ b/tests/users/common/mod.rs @@ -0,0 +1,31 @@ +use axum::{ + body::Body, + http::{request, Request}, +}; + +pub mod check; + +pub trait RequestBuilderExt { + fn json(self, json: serde_json::Value) -> Request; +} + +impl RequestBuilderExt for request::Builder { + fn json(self, json: serde_json::Value) -> Request { + self.header("Content-Type", "application/json") + .body(Body::from(json.to_string())) + .unwrap() + } +} + +pub fn remove_field(json: &mut serde_json::Value, ignore_fields: &Vec<&str>) { + match json { + serde_json::Value::Object(obj) => { + for field in ignore_fields { + obj.remove(*field); + } + } + _ => { + panic!("Invalid JSON format"); + } + } +} diff --git a/tests/users/fixtures/common.sql b/tests/users/fixtures/common.sql new file mode 100644 index 0000000..5d32597 --- /dev/null +++ b/tests/users/fixtures/common.sql @@ -0,0 +1,41 @@ +INSERT INTO users +(id, name, role, github_id, github_link, self_introduction, email) +VALUES +( + UNHEX(REPLACE('11111111-1111-1111-1111-111111111111','-','')), + 'test_user_1', + 0, + "test_github_id_1", + "https://github.com/test_user_1", + "test_self_introduction_1", + "test1@test.com" +); + +INSERT INTO users_passwords +(user_id, password) +VALUES +( + UNHEX(REPLACE('11111111-1111-1111-1111-111111111111','-','')), + "test_password_1" +); + +INSERT INTO +users (id, name, role, traq_id, x_link) +VALUES +( + UNHEX(REPLACE('22222222-2222-2222-2222-222222222222','-','')), + 'test_user_2', + 1, + "test_traq_id_2", + "https://x.com/test_user_2" +); + +INSERT INTO users +(id, name, role, icon_url) +VALUES +( + UNHEX(REPLACE('33333333-3333-3333-3333-333333333333','-','')), + 'test_user_3', + 2, + "https://icon.com/test_user_3" +); \ No newline at end of file diff --git a/tests/users/get_users.rs b/tests/users/get_users.rs new file mode 100644 index 0000000..0ad263c --- /dev/null +++ b/tests/users/get_users.rs @@ -0,0 +1,116 @@ +use std::borrow::BorrowMut; + +use super::common::check::users_check_by_id; +use axum::{ + body::Body, + http::{self, Request}, +}; +use http_body_util::BodyExt; +use serde_json::Value; +use tower::ServiceExt; +use trao_judge_backend::{make_router, Repository}; + +#[sqlx::test(fixtures("common"))] +async fn get_user_by_id(pool: sqlx::MySqlPool) -> anyhow::Result<()> { + let state = Repository::create_by_pool(pool).await?; + let mut app = make_router(state); + + let tests = vec![1, 2, 3]; + + for id in tests { + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/users/{}", id)) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), 200); + + let mut resp_json: Value = + serde_json::from_slice(&response.into_body().collect().await?.to_bytes())?; + + users_check_by_id(id, &mut resp_json)?; + } + + Ok(()) +} + +#[sqlx::test(fixtures("common"))] +async fn get_user_by_id_not_found(pool: sqlx::MySqlPool) -> anyhow::Result<()> { + let state = Repository::create_by_pool(pool).await?; + let mut app = make_router(state); + + let not_found_case = vec![0, 4, 10, 1000000]; + for id in not_found_case { + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/users/{}", id)) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), 404); + } + + Ok(()) +} + +#[sqlx::test(fixtures("common"))] +async fn get_user_me(pool: sqlx::MySqlPool) -> anyhow::Result<()> { + let state = Repository::create_by_pool(pool).await?; + let mut app = make_router(state.clone()); + + let tests = vec![1, 2, 3]; + + for id in tests { + let session_id = state + .create_session(state.get_user_by_display_id(id).await?.unwrap()) + .await?; + + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/users/me") + .header("Cookie", format!("session_id={}", session_id)) + .body(Body::empty())?, + ) + .await?; + assert_eq!(response.status(), 200); + + let mut resp_json: Value = + serde_json::from_slice(&response.into_body().collect().await?.to_bytes())?; + + users_check_by_id(id, &mut resp_json)?; + } + + Ok(()) +} + +#[sqlx::test(fixtures("common"))] +async fn get_user_me_unauthorized(pool: sqlx::MySqlPool) -> anyhow::Result<()> { + let state = Repository::create_by_pool(pool).await?; + let mut app = make_router(state.clone()); + + // Test unauthorized case + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/users/me") + .body(Body::empty())?, + ) + .await?; + assert_eq!(response.status(), 401); + + Ok(()) +} diff --git a/tests/users/mod.rs b/tests/users/mod.rs new file mode 100644 index 0000000..fd15c35 --- /dev/null +++ b/tests/users/mod.rs @@ -0,0 +1,4 @@ +pub mod common; + +mod get_users; +mod put_users; diff --git a/tests/users/put_users.rs b/tests/users/put_users.rs new file mode 100644 index 0000000..6ba0c1e --- /dev/null +++ b/tests/users/put_users.rs @@ -0,0 +1,153 @@ +use std::borrow::BorrowMut; + +use super::common::RequestBuilderExt; +use axum::{ + body::Body, + http::{self, Request}, +}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; +use trao_judge_backend::{make_router, Repository}; + +#[sqlx::test(fixtures("common"))] +async fn put_user_me(pool: sqlx::MySqlPool) -> anyhow::Result<()> { + let state = Repository::create_by_pool(pool).await?; + let mut app = make_router(state.clone()); + + let tests = vec![ + ( + 1, + json!({ + "userName": "tt", + "selfIntroduction": "hello", + }), + vec![("name", "tt"), ("selfIntroduction", "hello")], + ), + ( + 2, + json!({ + "userName": "t-t", + "xLink": "https://x.com/test", + }), + vec![("name", "t-t"), ("xLink", "https://x.com/test")], + ), + ( + 3, + json!({ + "userName": "t-t", + "xLink": "https://x.com/tester/t", + "selfIntroduction": "hello", + }), + vec![ + ("name", "t-t"), + ("xLink", "https://x.com/tester/t"), + ("selfIntroduction", "hello"), + ], + ), + ]; + + for (id, req_json, changes) in tests { + let session_id = state + .create_session(state.get_user_by_display_id(id).await?.unwrap()) + .await?; + + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::PUT) + .uri("/users/me") + .header("Cookie", format!("session_id={}", session_id)) + .json(req_json), + ) + .await?; + + assert_eq!(response.status(), 200); + + let resp_json: Value = + serde_json::from_slice(&response.into_body().collect().await?.to_bytes())?; + + for (key, value) in changes { + assert_eq!(resp_json[key], value); + } + + // get_users/me との比較 + let get_user_response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/users/me") + .header("Cookie", format!("session_id={}", session_id)) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(get_user_response.status(), 200); + let resp_json2: Value = + serde_json::from_slice(&get_user_response.into_body().collect().await?.to_bytes())?; + + assert_eq!(resp_json, resp_json2); + } + Ok(()) +} + +#[sqlx::test(fixtures("common"))] +async fn put_user_me_invalid(pool: sqlx::MySqlPool) -> anyhow::Result<()> { + let state = Repository::create_by_pool(pool).await?; + let mut app = make_router(state.clone()); + + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::PUT) + .uri("/users/me") + .json(json!({ + "userName": "test", + "xLink": "https://x.com/tester/t", + "selfIntroduction": "hello", + })), + ) + .await?; + + assert_eq!(response.status(), 401); + + let session_id = state + .create_session(state.get_user_by_display_id(1).await?.unwrap()) + .await?; + + let tests = vec![ + json!({ + "userName": "t-", + "xLink": "https://x.com/tester/t", + "selfIntroduction": "hello", + }), + json!({ + "userName": "t-t", + "xLink": "https://x.com", + }), + json!({ + "userName": "Test/Test", + "selfIntroduction": "hello", + }), + ]; + + for req_json in tests { + let response = app + .borrow_mut() + .oneshot( + Request::builder() + .method(http::Method::PUT) + .uri("/users/me") + .header("Cookie", format!("session_id={}", session_id)) + .json(req_json), + ) + .await?; + + assert_eq!(response.status(), 400); + } + + Ok(()) +}