diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..5d64798 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,91 @@ +name: Rust Build + +on: + pull_request: + branches: + - alot_of_changes + + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test Suite + runs-on: ubuntu-24.04 + # --- Add database service definition --- + services: + postgres: + image: postgres:18.3-alpine + env: + # Use secrets for DB init, with fallbacks for flexibility + POSTGRES_USER: ${{ secrets.DB_TEST_USER || 'testuser' }} + POSTGRES_PASSWORD: ${{ secrets.DB_TEST_PASSWORD || 'testpassword' }} + POSTGRES_DB: ${{ secrets.DB_TEST_NAME || 'testdb' }} + POSTGRES_PORT: ${{ secrets.DB_PORT || 5432 }} + # Options to wait until the database is ready + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: 1.96 + # --- Add this step for explicit verification --- + - name: Verify Docker Environment + run: | + echo "Runner User Info:" + id + echo "Checking Docker Version:" + docker --version + echo "Checking Docker Daemon Status (info):" + docker info + echo "Checking Docker Daemon Status (ps):" + docker ps -a + echo "Docker environment check complete." + # NOTE: Do NOT use continue-on-error here. + # If Docker isn't working as expected, the job SHOULD fail here. + - name: Run tests + env: + # Define DATABASE_URL for tests to use + DATABASE_URL: postgresql://${{ secrets.DB_TEST_USER || 'testuser' }}:${{ secrets.DB_TEST_PASSWORD || 'testpassword' }}@postgres:${{ secrets.DB_PORT || 5432 }}/${{ secrets.DB_TEST_NAME || 'testdb' }} + RUST_LOG: info # Optional: configure test log level + SECRET_MAIN_KEY: ${{ secrets.SECRET_KEY }} + # Make SSH agent available if tests fetch private dependencies + SSH_AUTH_SOCK: ${{ env.SSH_AUTH_SOCK }} + ENABLE_REGISTRATION: 'TRUE' + run: | + mkdir -p ~/.ssh + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/textsender-models_deploy_key + chmod 600 ~/.ssh/textsender-models_deploy_key + ssh-keyscan ${{ secrets.MY_HOST }} >> ~/.ssh/known_hosts + + eval $(ssh-agent -s) + ssh-add -v ~/.ssh/textsender-models_deploy_key + + cargo test + + fmt: + name: Rustfmt + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: 1.96 + - run: rustup component add rustfmt + - uses: Swatinem/rust-cache@v2 + - run: | + mkdir -p ~/.ssh + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/textsender-models_deploy_key + chmod 600 ~/.ssh/textsender-models_deploy_key + ssh-keyscan ${{ secrets.MY_HOST }} >> ~/.ssh/known_hosts + + eval $(ssh-agent -s) + ssh-add -v ~/.ssh/textsender-models_deploy_key + cargo fmt --all -- --check diff --git a/Cargo.lock b/Cargo.lock index 29d1fdd..993ac87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2293,22 +2293,19 @@ dependencies = [ [[package]] name = "textsender_api" -version = "0.1.8" +version = "0.1.9" dependencies = [ "axum", "axum-extra", - "base64", "common-multipart-rfc7578", "futures", "josekit", "jsonwebtoken", - "mime_guess", "serde", "serde_json", "sqlx", "tempfile", "textsender_models", - "thiserror 2.0.18", "time", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 5797080..44b5dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textsender_api" -version = "0.1.8" +version = "0.1.9" edition = "2024" rust-version = "1.96" @@ -15,12 +15,9 @@ tower = { version = "0.5.3", features = ["full"] } tower-http = { version = "0.6.11", features = ["cors", "timeout"] } tracing-subscriber = "0.3.23" futures = { version = "0.3.32" } -mime_guess = { version = "2.0.5" } uuid = { version = "1.23.3", features = ["v4", "serde"] } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "time", "uuid"] } time = { version = "0.3.49", features = ["formatting", "macros", "parsing", "serde"] } -thiserror = "2.0.18" -base64 = "0.22.1" jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } josekit = { version = "0.10.3" } utoipa = { version = "5.5.0", features = ["axum_extras"] } diff --git a/src/caller/contact.rs b/src/caller/contact.rs index 3b07568..6093df5 100644 --- a/src/caller/contact.rs +++ b/src/caller/contact.rs @@ -171,7 +171,7 @@ pub mod endpoint { } } - // Endpoint to get songs + /// Endpoint to get Contacts #[utoipa::path( get, path = super::super::endpoints::GET_CONTACT, diff --git a/src/caller/message.rs b/src/caller/message.rs index 05d8280..3e07c0a 100644 --- a/src/caller/message.rs +++ b/src/caller/message.rs @@ -13,6 +13,12 @@ pub mod request { !self.content.is_empty() || !self.user_id.is_nil() } } + + #[derive(Debug, Default, serde::Deserialize, serde::Serialize, utoipa::ToSchema)] + pub struct GetMessageParams { + pub id: Option, + pub user_id: Option, + } } pub mod response { @@ -24,6 +30,8 @@ pub mod response { pub message: String, pub data: Vec, } + + pub use AddMessageResponse as GetMessageResponse; } pub mod endpoint { @@ -81,4 +89,63 @@ pub mod endpoint { (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) } } + + /// Endpoint to get Messages + #[utoipa::path( + get, + path = super::super::endpoints::GET_MESSAGE, + params( + ("id" = uuid::Uuid, Path, description = "Id of Message"), + ("user_id" = uuid::Uuid, Path, description = "User Id associated with the Message") + ), + responses( + (status = 200, description = "Songs found", body = super::response::GetMessageResponse), + (status = 400, description = "Error getting songs", body = super::response::GetMessageResponse) + ) + )] + pub async fn get_messages( + axum::Extension(pool): axum::Extension, + axum::extract::Query(params): axum::extract::Query, + ) -> ( + axum::http::StatusCode, + axum::Json, + ) { + let mut response = super::response::GetMessageResponse::default(); + + let messages = match params.id { + Some(id) => match message_repo::get(&pool, &id).await { + Ok(message) => { + vec![message] + } + Err(err) => { + eprintln!("Error: {err:?}"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response), + ); + } + }, + None => match params.user_id { + Some(user_id) => match message_repo::get_with_user_id(&pool, &user_id).await { + Ok(messages) => messages, + Err(err) => { + eprintln!("Error: {err:?}"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response), + ); + } + }, + None => { + response.message = String::from("Invalid parameter"); + return (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)); + } + }, + }; + + response.data = messages; + response.message = String::from(super::super::response::SUCCESSFUL); + + (axum::http::StatusCode::OK, axum::Json(response)) + } } diff --git a/src/caller/mod.rs b/src/caller/mod.rs index 85b102e..3a9e62a 100644 --- a/src/caller/mod.rs +++ b/src/caller/mod.rs @@ -12,6 +12,8 @@ pub mod endpoints { pub const UPDATE_CONTACT_NAME: &str = "/api/v1/contact/update"; /// Constant for adding message endpoint pub const ADD_MESSAGE: &str = "/api/v1/message/new"; + /// Constant for getting messages endpoint + pub const GET_MESSAGE: &str = "/api/v1/message"; } pub mod response { diff --git a/src/config/mod.rs b/src/config/mod.rs index f8a03e9..111e1b0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -111,6 +111,12 @@ pub mod init { crate::auth::auth::, )), ) + .route( + crate::caller::endpoints::GET_MESSAGE, + get(message_endpoints::get_messages).route_layer(axum::middleware::from_fn( + crate::auth::auth::, + )), + ) .layer(cors::configure_cors().await) } diff --git a/src/repo/message.rs b/src/repo/message.rs index 312e893..0e3b015 100644 --- a/src/repo/message.rs +++ b/src/repo/message.rs @@ -23,3 +23,69 @@ pub async fn insert( Err(_) => Err(sqlx::Error::RowNotFound), } } + +pub async fn get( + pool: &sqlx::PgPool, + id: &uuid::Uuid, +) -> Result { + match sqlx::query( + r#" + SELECT id, content, user_id FROM "messages" + WHERE + id = $1; + "#, + ) + .bind(id) + .fetch_one(pool) + .await + { + Ok(row) => { + let id: uuid::Uuid = row.try_get("id")?; + + let content: String = row.try_get("content")?; + let user_id: uuid::Uuid = row.try_get("user_id")?; + Ok(textsender_models::message::Message { + id: Some(id), + content, + user_id: Some(user_id), + }) + } + Err(_) => Err(sqlx::Error::RowNotFound), + } +} + +pub async fn get_with_user_id( + pool: &sqlx::PgPool, + user_id: &uuid::Uuid, +) -> Result, sqlx::Error> { + match sqlx::query( + r#" + SELECT id, content, user_id FROM "messages" + WHERE + user_id = $1; + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await + { + Ok(rows) => { + let mut messages: Vec = Vec::new(); + for row in rows { + let id: uuid::Uuid = row.try_get("id")?; + let content: String = row.try_get("content")?; + let user_id: uuid::Uuid = row.try_get("user_id")?; + + let message = textsender_models::message::Message { + id: Some(id), + content, + user_id: Some(user_id), + }; + messages.push(message); + } + + Ok(messages) + } + Err(_) => Err(sqlx::Error::RowNotFound), + } +} diff --git a/tests/test.rs b/tests/test.rs index 801fc62..6bb0b09 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -129,8 +129,22 @@ pub fn token_fields() -> (String, String, String) { ) } +/// Test User Id pub const TEST_USER_ID: uuid::Uuid = uuid::uuid!("cc938368-615a-4694-b2ca-6e122fa31c52"); +/// Test contact phone number +pub const TEST_CONTACT_PHONE_NUMBER: &str = "+10123456789"; + +/// Test contact updated firstname +pub const TEST_CONTACT_UPDATED_FIRSTNAME: &str = "Johnny"; +/// Test contact updated lastname +pub const TEST_CONTACT_UPDATED_LASTNAME: &str = "CASH"; +/// Test contact updated nickname +pub const TEST_CONTACT_UPDATED_NICKNAME: &str = "The Man in Black"; + +/// Test message content +pub const TEST_MESSAGE_CONTENT: &str = "The wind cries mary"; + pub async fn test_token() -> Result { let key: String = textsender_models::envy::environment::get_secret_main_key() .await @@ -170,47 +184,45 @@ mod request { pub async fn create_contact( app: &axum::Router, - ) -> Result { + ) -> Result { let payload = serde_json::json!({ "phone_number": super::TEST_CONTACT_PHONE_NUMBER, "user_id": super::TEST_USER_ID, }); - let req = axum::http::Request::builder() - .method(axum::http::Method::POST) - .uri(textsender_api::caller::endpoints::ADD_CONTACT) - .header(axum::http::header::CONTENT_TYPE, "application/json") - .header( - axum::http::header::AUTHORIZATION, - super::bearer_auth().await, - ) - .body(axum::body::Body::from(payload.to_string())) - .unwrap(); - app.clone().oneshot(req).await + match run_post( + Some(&payload), + textsender_api::caller::endpoints::ADD_CONTACT, + axum::http::Method::POST, + true, + ) + .await + { + Ok(req) => match app.clone().oneshot(req).await { + Ok(response) => Ok(response), + Err(err) => Err(axum::http::Error::from(err)), + }, + Err(err) => Err(err), + } } pub async fn get_contact( app: &axum::Router, id: &uuid::Uuid, - ) -> Result { + ) -> Result { let uri = format!( "{}?id={}", textsender_api::caller::endpoints::GET_CONTACT, id ); - let req = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri(uri) - .header(axum::http::header::CONTENT_TYPE, "application/json") - .header( - axum::http::header::AUTHORIZATION, - super::bearer_auth().await, - ) - .body(axum::body::Body::empty()) - .unwrap(); - - app.clone().oneshot(req).await + match run_post(None, &uri, axum::http::Method::GET, false).await { + Ok(req) => match app.clone().oneshot(req).await { + Ok(response) => Ok(response), + Err(err) => Err(axum::http::Error::from(err)), + }, + Err(err) => Err(err), + } } pub async fn update_contact_names( @@ -219,7 +231,7 @@ mod request { firstname: Option<&str>, lastname: Option<&str>, nickname: Option<&str>, - ) -> Result { + ) -> Result { let payload = serde_json::json!({ "firstname": firstname, "lastname": lastname, @@ -228,30 +240,101 @@ mod request { "id": id }); - let req = axum::http::Request::builder() - .method(axum::http::Method::PATCH) - .uri(textsender_api::caller::endpoints::UPDATE_CONTACT_NAME) + match run_post( + Some(&payload), + textsender_api::caller::endpoints::UPDATE_CONTACT_NAME, + axum::http::Method::PATCH, + true, + ) + .await + { + Ok(req) => match app.clone().oneshot(req).await { + Ok(response) => Ok(response), + Err(err) => Err(axum::http::Error::from(err)), + }, + Err(err) => Err(err), + } + } + + pub mod message { + use tower::ServiceExt; + + pub async fn create_message( + app: &axum::Router, + ) -> Result { + let payload = serde_json::json!({ + "content": super::super::TEST_MESSAGE_CONTENT, + "user_id": super::super::TEST_USER_ID, + }); + + match super::run_post( + Some(&payload), + textsender_api::caller::endpoints::ADD_MESSAGE, + axum::http::Method::POST, + true, + ) + .await + { + Ok(req) => match app.clone().oneshot(req).await { + Ok(response) => Ok(response), + Err(err) => Err(axum::http::Error::from(err)), + }, + Err(err) => Err(err), + } + } + + pub async fn get_message( + app: &axum::Router, + id: &uuid::Uuid, + ) -> Result { + let uri = format!( + "{}?id={}", + textsender_api::caller::endpoints::GET_MESSAGE, + id + ); + + match super::run_post(None, &uri, axum::http::Method::GET, false).await { + Ok(req) => match app.clone().oneshot(req).await { + Ok(response) => Ok(response), + Err(err) => Err(axum::http::Error::from(err)), + }, + Err(err) => Err(err), + } + } + } + + pub async fn run_post( + payload: Option<&serde_json::Value>, + uri: &str, + method: axum::http::Method, + has_body: bool, + ) -> Result, axum::http::Error> { + let body = if has_body { + assert_eq!( + true, + payload.is_some(), + "Has request body and payload has data" + ); + axum::body::Body::from(payload.unwrap().to_string()) + } else { + axum::body::Body::empty() + }; + match axum::http::Request::builder() + .method(method) + .uri(uri) .header(axum::http::header::CONTENT_TYPE, "application/json") .header( axum::http::header::AUTHORIZATION, super::bearer_auth().await, ) - .body(axum::body::Body::from(payload.to_string())) - .unwrap(); - app.clone().oneshot(req).await + .body(body) + { + Ok(t) => Ok(t), + Err(err) => Err(err), + } } } -/// Test contact phone number -pub const TEST_CONTACT_PHONE_NUMBER: &str = "+10123456789"; - -/// Test contact updated firstname -pub const TEST_CONTACT_UPDATED_FIRSTNAME: &str = "Johnny"; -/// Test contact updated lastname -pub const TEST_CONTACT_UPDATED_LASTNAME: &str = "CASH"; -/// Test contact updated nickname -pub const TEST_CONTACT_UPDATED_NICKNAME: &str = "The Man in Black"; - #[tokio::test] async fn test_create_contact() { let tm_pool = db_mgr::get_pool().await.unwrap(); @@ -433,3 +516,104 @@ async fn test_update_contact_names() { let _ = db_mgr::drop_database(&tm_pool, &db_name).await; } + +#[tokio::test] +async fn test_create_message() { + let tm_pool = db_mgr::get_pool().await.unwrap(); + let db_name = db_mgr::generate_db_name().await; + + match db_mgr::create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + + let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); + db::migrations(&pool).await; + + let app = init::app(pool).await; + + // Send request + match request::message::create_message(&app).await { + Ok(response) => { + let resp = util::get_resp_data::< + textsender_api::caller::message::response::AddMessageResponse, + >(response) + .await; + assert_eq!(false, resp.data.is_empty(), "Should not be empty"); + let message = &resp.data[0]; + assert_eq!( + TEST_MESSAGE_CONTENT, message.content, + "The content of the created message should match" + ); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + }; + + let _ = db_mgr::drop_database(&tm_pool, &db_name).await; +} + +#[tokio::test] +async fn test_get_message() { + let tm_pool = db_mgr::get_pool().await.unwrap(); + let db_name = db_mgr::generate_db_name().await; + + match db_mgr::create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + + let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); + db::migrations(&pool).await; + + let app = init::app(pool).await; + let mut message_result: Option = None; + + // Send request + match request::message::create_message(&app).await { + Ok(response) => { + let resp = util::get_resp_data::< + textsender_api::caller::message::response::AddMessageResponse, + >(response) + .await; + assert_eq!(false, resp.data.is_empty(), "Should not be empty"); + let message = &resp.data[0]; + assert_eq!( + TEST_MESSAGE_CONTENT, message.content, + "The content of the created message should match" + ); + message_result = Some(message.clone()); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + }; + + assert_eq!(true, message_result.is_some()); + + let message = message_result.unwrap(); + let message_id = message.id.unwrap(); + match request::message::get_message(&app, &message_id).await { + Ok(response) => { + let resp = util::get_resp_data::< + textsender_api::caller::message::response::GetMessageResponse, + >(response) + .await; + assert_eq!(false, resp.data.is_empty(), "Should not be empty"); + } + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } + + let _ = db_mgr::drop_database(&tm_pool, &db_name).await; +}