From e1a8e21af1826f65978f99210895e53c9a1cbfd0 Mon Sep 17 00:00:00 2001 From: raza-khan0108 Date: Fri, 6 Feb 2026 23:52:20 +0530 Subject: [PATCH] Add project likes backend endpoints --- .../006_create_project_likes_table.sql | 10 + .../commands/create_project_like.rs | 46 +++ .../commands/delete_project_like.rs | 40 +++ backend/src/application/commands/mod.rs | 2 + backend/src/application/dtos/like_dtos.rs | 21 ++ backend/src/application/dtos/mod.rs | 2 + .../application/queries/get_project_likes.rs | 53 ++++ backend/src/application/queries/mod.rs | 1 + backend/src/domain/entities/mod.rs | 1 + backend/src/domain/entities/project_like.rs | 10 + backend/src/domain/repositories/mod.rs | 2 + .../repositories/project_like_repository.rs | 39 +++ .../src/infrastructure/repositories/mod.rs | 2 + .../postgres_project_like_repository.rs | 140 ++++++++ backend/src/presentation/api.rs | 17 +- backend/src/presentation/handlers.rs | 99 ++++++ backend/tests/integration_github_handle.rs | 32 +- backend/tests/project_likes_api_tests.rs | 300 ++++++++++++++++++ 18 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/006_create_project_likes_table.sql create mode 100644 backend/src/application/commands/create_project_like.rs create mode 100644 backend/src/application/commands/delete_project_like.rs create mode 100644 backend/src/application/dtos/like_dtos.rs create mode 100644 backend/src/application/queries/get_project_likes.rs create mode 100644 backend/src/domain/entities/project_like.rs create mode 100644 backend/src/domain/repositories/project_like_repository.rs create mode 100644 backend/src/infrastructure/repositories/postgres_project_like_repository.rs create mode 100644 backend/tests/project_likes_api_tests.rs diff --git a/backend/migrations/006_create_project_likes_table.sql b/backend/migrations/006_create_project_likes_table.sql new file mode 100644 index 0000000..8ef56d1 --- /dev/null +++ b/backend/migrations/006_create_project_likes_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS project_likes ( + project_id UUID NOT NULL, + user_address VARCHAR(42) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_address), + CONSTRAINT fk_project_likes_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_project_likes_project_id ON project_likes(project_id); +CREATE INDEX IF NOT EXISTS idx_project_likes_user_address ON project_likes(user_address); diff --git a/backend/src/application/commands/create_project_like.rs b/backend/src/application/commands/create_project_like.rs new file mode 100644 index 0000000..7d2c785 --- /dev/null +++ b/backend/src/application/commands/create_project_like.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use crate::{ + application::dtos::like_dtos::ProjectLikeCreatedResponse, + domain::{ + entities::projects::ProjectId, + repositories::{ProjectLikeRepository, ProjectRepository}, + value_objects::WalletAddress, + }, +}; + +pub async fn create_project_like( + project_repository: Arc, + like_repository: Arc, + user_address: String, + project_id: String, +) -> Result { + let user_address = WalletAddress::new(user_address.to_lowercase()) + .map_err(|e| format!("Invalid wallet address: {}", e))?; + + let uuid = uuid::Uuid::parse_str(&project_id).map_err(|_| "Invalid project id".to_string())?; + let project_id = ProjectId::from_uuid(uuid); + + let exists = project_repository + .exists(&project_id) + .await + .map_err(|e| e.to_string())?; + if !exists { + return Err("Project not found".to_string()); + } + + let created = like_repository + .create(&project_id, &user_address) + .await + .map_err(|e| e.to_string())?; + + if !created { + return Err("Like already exists".to_string()); + } + + Ok(ProjectLikeCreatedResponse { + project_id: project_id.value().to_string(), + user_address: user_address.to_string(), + }) +} + diff --git a/backend/src/application/commands/delete_project_like.rs b/backend/src/application/commands/delete_project_like.rs new file mode 100644 index 0000000..87eb269 --- /dev/null +++ b/backend/src/application/commands/delete_project_like.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::domain::{ + entities::projects::ProjectId, + repositories::{ProjectLikeRepository, ProjectRepository}, + value_objects::WalletAddress, +}; + +pub async fn delete_project_like( + project_repository: Arc, + like_repository: Arc, + user_address: String, + project_id: String, +) -> Result<(), String> { + let user_address = WalletAddress::new(user_address.to_lowercase()) + .map_err(|e| format!("Invalid wallet address: {}", e))?; + + let uuid = uuid::Uuid::parse_str(&project_id).map_err(|_| "Invalid project id".to_string())?; + let project_id = ProjectId::from_uuid(uuid); + + let exists = project_repository + .exists(&project_id) + .await + .map_err(|e| e.to_string())?; + if !exists { + return Err("Project not found".to_string()); + } + + let deleted = like_repository + .delete(&project_id, &user_address) + .await + .map_err(|e| e.to_string())?; + + if !deleted { + return Err("Like not found".to_string()); + } + + Ok(()) +} + diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index 2b63c7a..2687650 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -1,5 +1,7 @@ pub mod create_profile; +pub mod create_project_like; pub mod create_project; +pub mod delete_project_like; pub mod delete_project; pub mod login; pub mod update_profile; diff --git a/backend/src/application/dtos/like_dtos.rs b/backend/src/application/dtos/like_dtos.rs new file mode 100644 index 0000000..dd9a981 --- /dev/null +++ b/backend/src/application/dtos/like_dtos.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectLikeResponse { + pub user_address: String, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectLikesResponse { + pub project_id: String, + pub total: i64, + pub likes: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectLikeCreatedResponse { + pub project_id: String, + pub user_address: String, +} diff --git a/backend/src/application/dtos/mod.rs b/backend/src/application/dtos/mod.rs index 1e7059a..0daae53 100644 --- a/backend/src/application/dtos/mod.rs +++ b/backend/src/application/dtos/mod.rs @@ -1,6 +1,8 @@ pub mod auth_dtos; +pub mod like_dtos; pub mod profile_dtos; pub mod project_dtos; pub use auth_dtos::*; +pub use like_dtos::*; pub use profile_dtos::*; pub use project_dtos::*; diff --git a/backend/src/application/queries/get_project_likes.rs b/backend/src/application/queries/get_project_likes.rs new file mode 100644 index 0000000..149856b --- /dev/null +++ b/backend/src/application/queries/get_project_likes.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use crate::{ + application::dtos::like_dtos::{ProjectLikeResponse, ProjectLikesResponse}, + domain::{ + entities::projects::ProjectId, + repositories::{ProjectLikeRepository, ProjectRepository}, + }, +}; + +pub async fn get_project_likes( + project_repository: Arc, + like_repository: Arc, + project_id: String, + limit: Option, + offset: Option, +) -> Result { + let uuid = uuid::Uuid::parse_str(&project_id).map_err(|_| "Invalid project id".to_string())?; + let project_id = ProjectId::from_uuid(uuid); + + let exists = project_repository + .exists(&project_id) + .await + .map_err(|e| e.to_string())?; + if !exists { + return Err("Project not found".to_string()); + } + + let limit = limit.unwrap_or(50).clamp(1, 100); + let offset = offset.unwrap_or(0).max(0); + + let total = like_repository + .count_by_project(&project_id) + .await + .map_err(|e| e.to_string())?; + let likes = like_repository + .list_by_project(&project_id, limit, offset) + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(|l| ProjectLikeResponse { + user_address: l.user_address.to_string(), + created_at: l.created_at, + }) + .collect(); + + Ok(ProjectLikesResponse { + project_id: project_id.value().to_string(), + total, + likes, + }) +} + diff --git a/backend/src/application/queries/mod.rs b/backend/src/application/queries/mod.rs index 7874c61..117a36d 100644 --- a/backend/src/application/queries/mod.rs +++ b/backend/src/application/queries/mod.rs @@ -5,3 +5,4 @@ pub mod get_profile; pub mod get_projects_by_creator; pub mod get_project; +pub mod get_project_likes; diff --git a/backend/src/domain/entities/mod.rs b/backend/src/domain/entities/mod.rs index 703c49d..9674aa8 100644 --- a/backend/src/domain/entities/mod.rs +++ b/backend/src/domain/entities/mod.rs @@ -1,5 +1,6 @@ pub mod profile; pub mod projects; +pub mod project_like; pub use profile::Profile; pub use projects::{Project, ProjectId, ProjectStatus}; diff --git a/backend/src/domain/entities/project_like.rs b/backend/src/domain/entities/project_like.rs new file mode 100644 index 0000000..7f24b74 --- /dev/null +++ b/backend/src/domain/entities/project_like.rs @@ -0,0 +1,10 @@ +use chrono::{DateTime, Utc}; + +use crate::domain::{entities::projects::ProjectId, value_objects::WalletAddress}; + +#[derive(Debug, Clone)] +pub struct ProjectLike { + pub project_id: ProjectId, + pub user_address: WalletAddress, + pub created_at: DateTime, +} diff --git a/backend/src/domain/repositories/mod.rs b/backend/src/domain/repositories/mod.rs index fe48bd4..ffb6087 100644 --- a/backend/src/domain/repositories/mod.rs +++ b/backend/src/domain/repositories/mod.rs @@ -1,5 +1,7 @@ pub mod profile_repository; +pub mod project_like_repository; pub mod project_repository; pub use profile_repository::ProfileRepository; +pub use project_like_repository::ProjectLikeRepository; pub use project_repository::ProjectRepository; diff --git a/backend/src/domain/repositories/project_like_repository.rs b/backend/src/domain/repositories/project_like_repository.rs new file mode 100644 index 0000000..e510888 --- /dev/null +++ b/backend/src/domain/repositories/project_like_repository.rs @@ -0,0 +1,39 @@ +use async_trait::async_trait; + +use crate::domain::{ + entities::{project_like::ProjectLike, projects::ProjectId}, + value_objects::WalletAddress, +}; + +#[async_trait] +pub trait ProjectLikeRepository: Send + Sync { + async fn create( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result>; + + async fn delete( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result>; + + async fn list_by_project( + &self, + project_id: &ProjectId, + limit: i64, + offset: i64, + ) -> Result, Box>; + + async fn count_by_project( + &self, + project_id: &ProjectId, + ) -> Result>; + + async fn exists( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result>; +} diff --git a/backend/src/infrastructure/repositories/mod.rs b/backend/src/infrastructure/repositories/mod.rs index ac8a7e3..fa78aaa 100644 --- a/backend/src/infrastructure/repositories/mod.rs +++ b/backend/src/infrastructure/repositories/mod.rs @@ -1,5 +1,7 @@ pub mod postgres_profile_repository; +pub mod postgres_project_like_repository; pub mod postgres_project_repository; pub use postgres_profile_repository::PostgresProfileRepository; +pub use postgres_project_like_repository::PostgresProjectLikeRepository; pub use postgres_project_repository::PostgresProjectRepository; diff --git a/backend/src/infrastructure/repositories/postgres_project_like_repository.rs b/backend/src/infrastructure/repositories/postgres_project_like_repository.rs new file mode 100644 index 0000000..aeeb52a --- /dev/null +++ b/backend/src/infrastructure/repositories/postgres_project_like_repository.rs @@ -0,0 +1,140 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::{ + entities::{ + project_like::ProjectLike, + projects::ProjectId, + }, + repositories::project_like_repository::ProjectLikeRepository, + value_objects::WalletAddress, +}; + +#[derive(Clone)] +pub struct PostgresProjectLikeRepository { + pool: PgPool, +} + +impl PostgresProjectLikeRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ProjectLikeRepository for PostgresProjectLikeRepository { + async fn create( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result> { + let result = sqlx::query( + r#" + INSERT INTO project_likes (project_id, user_address) + VALUES ($1, $2) + ON CONFLICT (project_id, user_address) DO NOTHING + "#, + ) + .bind(project_id.value()) + .bind(user_address.as_str()) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(result.rows_affected() == 1) + } + + async fn delete( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result> { + let result = sqlx::query( + r#" + DELETE FROM project_likes + WHERE project_id = $1 AND user_address = $2 + "#, + ) + .bind(project_id.value()) + .bind(user_address.as_str()) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(result.rows_affected() == 1) + } + + async fn list_by_project( + &self, + project_id: &ProjectId, + limit: i64, + offset: i64, + ) -> Result, Box> { + let rows = sqlx::query_as::<_, (Uuid, String, DateTime)>( + r#" + SELECT project_id, user_address, created_at + FROM project_likes + WHERE project_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(project_id.value()) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(rows + .into_iter() + .map(|(project_id, user_address, created_at)| ProjectLike { + project_id: ProjectId::from_uuid(project_id), + user_address: WalletAddress(user_address), + created_at, + }) + .collect()) + } + + async fn count_by_project( + &self, + project_id: &ProjectId, + ) -> Result> { + let count = sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*) + FROM project_likes + WHERE project_id = $1 + "#, + ) + .bind(project_id.value()) + .fetch_one(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(count) + } + + async fn exists( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result> { + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM project_likes WHERE project_id = $1 AND user_address = $2 + ) + "#, + ) + .bind(project_id.value()) + .bind(user_address.as_str()) + .fetch_one(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(exists) + } +} diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index bef05ef..b4ac74f 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -1,10 +1,11 @@ use std::sync::Arc; -use crate::domain::repositories::{ProfileRepository, ProjectRepository}; +use crate::domain::repositories::{ProfileRepository, ProjectLikeRepository, ProjectRepository}; use crate::domain::services::auth_service::AuthService; use crate::infrastructure::{ repositories::{ postgres_project_repository::PostgresProjectRepository, PostgresProfileRepository, + PostgresProjectLikeRepository, }, services::ethereum_address_verification_service::EthereumAddressVerificationService, }; @@ -30,13 +31,16 @@ use super::handlers::{ create_project_handler, delete_profile_handler, delete_project_handler, + get_project_likes_handler, get_all_profiles_handler, get_nonce_handler, get_profile_handler, get_project_handler, get_user_projects_handler, + like_project_handler, list_projects_handler, login_handler, + unlike_project_handler, update_profile_handler, update_project_handler, }; @@ -45,12 +49,14 @@ use super::middlewares::{admin_auth_layer, eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { let profile_repository = Arc::from(PostgresProfileRepository::new(pool.clone())); - let project_repository = Arc::from(PostgresProjectRepository::new(pool)); + let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let project_like_repository = Arc::from(PostgresProjectLikeRepository::new(pool.clone())); let auth_service = EthereumAddressVerificationService::new(profile_repository.clone()); let state: AppState = AppState { profile_repository, project_repository, + project_like_repository, auth_service: Arc::from(auth_service), }; @@ -66,6 +72,8 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { .route("/projects", post(create_project_handler)) .route("/projects/:id", patch(update_project_handler)) .route("/projects/:id", delete(delete_project_handler)) + .route("/projects/:id/likes", post(like_project_handler)) + .route("/projects/:id/likes", delete(unlike_project_handler)) .with_state(state.clone()); let protected_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -98,6 +106,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { // Project public routes .route("/projects", get(list_projects_handler)) .route("/projects/:id", get(get_project_handler)) + .route("/projects/:id/likes", get(get_project_likes_handler)) .route("/users/:address/projects", get(get_user_projects_handler)) .with_state(state.clone()); @@ -129,6 +138,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { pub struct AppState { pub profile_repository: Arc, pub project_repository: Arc, + pub project_like_repository: Arc, pub auth_service: Arc, } @@ -144,6 +154,8 @@ pub fn test_api(state: AppState) -> Router { .route("/projects", post(create_project_handler)) .route("/projects/:id", patch(update_project_handler)) .route("/projects/:id", delete(delete_project_handler)) + .route("/projects/:id/likes", post(like_project_handler)) + .route("/projects/:id/likes", delete(unlike_project_handler)) .with_state(state.clone()) .layer(from_fn(test_auth_layer)); @@ -165,6 +177,7 @@ pub fn test_api(state: AppState) -> Router { // Project public routes .route("/projects", get(list_projects_handler)) .route("/projects/:id", get(get_project_handler)) + .route("/projects/:id/likes", get(get_project_likes_handler)) .route("/users/:address/projects", get(get_user_projects_handler)) .with_state(state.clone()); diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 16cf092..901a409 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -27,10 +27,13 @@ use crate::application::{ commands::{ create_project::create_project, delete_project::delete_project, update_project::update_project, + create_project_like::create_project_like, + delete_project_like::delete_project_like, }, dtos::project_dtos::{CreateProjectRequest, UpdateProjectRequest}, queries::{ get_all_projects::get_all_projects, get_project::get_project, + get_project_likes::get_project_likes, get_projects_by_creator::get_projects_by_creator, }, }; @@ -46,6 +49,13 @@ pub struct ListProjectsQuery { pub offset: Option, } +/// Query parameters for listing project likes +#[derive(Debug, Deserialize)] +pub struct ListProjectLikesQuery { + pub limit: Option, + pub offset: Option, +} + pub async fn create_profile_handler( State(state): State, Extension(VerifiedWallet(wallet)): Extension, @@ -253,6 +263,95 @@ pub async fn delete_project_handler( } } +/// POST /projects/:id/likes - Like a project (Protected) +pub async fn like_project_handler( + State(state): State, + Extension(VerifiedWallet(verified_wallet)): Extension, + Path(id): Path, +) -> impl IntoResponse { + match create_project_like( + state.project_repository.clone(), + state.project_like_repository.clone(), + verified_wallet, + id, + ) + .await + { + Ok(resp) => (StatusCode::CREATED, Json(resp)).into_response(), + Err(e) => { + let status = if e.contains("already exists") { + StatusCode::CONFLICT + } else if e.contains("not found") { + StatusCode::NOT_FOUND + } else if e.contains("Invalid project id") { + StatusCode::BAD_REQUEST + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } + } +} + +/// DELETE /projects/:id/likes - Unlike a project (Protected) +pub async fn unlike_project_handler( + State(state): State, + Extension(VerifiedWallet(verified_wallet)): Extension, + Path(id): Path, +) -> impl IntoResponse { + match delete_project_like( + state.project_repository.clone(), + state.project_like_repository.clone(), + verified_wallet, + id, + ) + .await + { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + let status = if e.contains("Like not found") { + StatusCode::NOT_FOUND + } else if e.contains("Project not found") { + StatusCode::NOT_FOUND + } else if e.contains("Invalid project id") { + StatusCode::BAD_REQUEST + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } + } +} + +/// GET /projects/:id/likes - List likes for a project (Public) +pub async fn get_project_likes_handler( + State(state): State, + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + match get_project_likes( + state.project_repository.clone(), + state.project_like_repository.clone(), + id, + params.limit, + params.offset, + ) + .await + { + Ok(resp) => (StatusCode::OK, Json(resp)).into_response(), + Err(e) => { + let status = if e.contains("Project not found") { + StatusCode::NOT_FOUND + } else if e.contains("Invalid project id") { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } + } +} + // ============================================================================ // Admin Handlers // ============================================================================ diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index cdb7bfa..6a5496f 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -1,10 +1,24 @@ use guild_backend::application::dtos::profile_dtos::ProfileResponse; use guild_backend::infrastructure::repositories::postgres_project_repository::PostgresProjectRepository; +use guild_backend::infrastructure::repositories::PostgresProjectLikeRepository; use guild_backend::presentation::api::{test_api, AppState}; use serde_json::json; use std::sync::Arc; use tokio::net::TcpListener; +async fn try_connect_pool(database_url: &str) -> Option { + match sqlx::PgPool::connect(database_url).await { + Ok(pool) => Some(pool), + Err(e) => { + eprintln!( + "Skipping integration test (Postgres unavailable at DATABASE_URL): {}", + e + ); + None + } + } +} + #[tokio::test] async fn valid_github_handle_works() { std::env::set_var("TEST_MODE", "1"); @@ -14,16 +28,20 @@ async fn valid_github_handle_works() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + let Some(pool) = try_connect_pool(&database_url).await else { + return; + }; let profile_repository = std::sync::Arc::new( guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let project_like_repository = Arc::from(PostgresProjectLikeRepository::new(pool.clone())); let state = AppState { profile_repository, project_repository, + project_like_repository, auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -84,16 +102,20 @@ async fn invalid_format_rejected() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + let Some(pool) = try_connect_pool(&database_url).await else { + return; + }; let profile_repository = std::sync::Arc::new( guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let project_like_repository = Arc::from(PostgresProjectLikeRepository::new(pool.clone())); let state = AppState { profile_repository, project_repository, + project_like_repository, auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -160,16 +182,20 @@ async fn conflict_case_insensitive() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + let Some(pool) = try_connect_pool(&database_url).await else { + return; + }; let profile_repository = std::sync::Arc::new( guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let project_like_repository = Arc::from(PostgresProjectLikeRepository::new(pool.clone())); let state = AppState { profile_repository, project_repository, + project_like_repository, auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); diff --git a/backend/tests/project_likes_api_tests.rs b/backend/tests/project_likes_api_tests.rs new file mode 100644 index 0000000..22b77c0 --- /dev/null +++ b/backend/tests/project_likes_api_tests.rs @@ -0,0 +1,300 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use guild_backend::{ + domain::{ + entities::{ + profile::Profile, + project_like::ProjectLike, + projects::{Project, ProjectId, ProjectStatus}, + }, + repositories::{ProfileRepository, ProjectLikeRepository, ProjectRepository}, + services::auth_service::{AuthChallenge, AuthResult, AuthService}, + value_objects::WalletAddress, + }, + presentation::api::{test_api, AppState}, +}; +use std::{collections::HashSet, sync::Arc}; +use tower::ServiceExt; + +#[derive(Default)] +struct FakeProfileRepo; + +#[async_trait::async_trait] +impl ProfileRepository for FakeProfileRepo { + async fn find_by_address( + &self, + _address: &WalletAddress, + ) -> Result, Box> { + Ok(None) + } + + async fn find_all(&self) -> Result, Box> { + Ok(vec![]) + } + + async fn create(&self, _profile: &Profile) -> Result<(), Box> { + Ok(()) + } + + async fn update(&self, _profile: &Profile) -> Result<(), Box> { + Ok(()) + } + + async fn delete(&self, _address: &WalletAddress) -> Result<(), Box> { + Ok(()) + } + + async fn find_by_github_login( + &self, + _github_login: &str, + ) -> Result, Box> { + Ok(None) + } + + async fn find_by_twitter_handle( + &self, + _twitter_handle: &str, + ) -> Result, Box> { + Ok(None) + } + + async fn get_login_nonce_by_wallet_address( + &self, + _address: &WalletAddress, + ) -> Result, Box> { + Ok(None) + } + + async fn increment_login_nonce( + &self, + _address: &WalletAddress, + ) -> Result<(), Box> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeAuthService; + +#[async_trait::async_trait] +impl AuthService for FakeAuthService { + async fn verify_signature( + &self, + _challenge: &AuthChallenge, + _signature: &str, + ) -> Result, Box> { + Ok(None) + } +} + +#[derive(Default)] +struct FakeProjectRepo { + projects: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl ProjectRepository for FakeProjectRepo { + async fn create(&self, project: &Project) -> Result<(), Box> { + self.projects.lock().unwrap().insert(project.id); + Ok(()) + } + + async fn find_by_id( + &self, + id: &ProjectId, + ) -> Result, Box> { + let exists = self.projects.lock().unwrap().contains(id); + Ok(exists.then(|| Project::new( + "x".into(), + "y".into(), + ProjectStatus::Proposal, + WalletAddress::new("0x0000000000000000000000000000000000000000".into()).unwrap(), + ))) + } + + async fn find_all( + &self, + _status: Option, + _creator: Option<&WalletAddress>, + _limit: Option, + _offset: Option, + ) -> Result, Box> { + Ok(vec![]) + } + + async fn find_by_creator( + &self, + _creator: &WalletAddress, + ) -> Result, Box> { + Ok(vec![]) + } + + async fn update(&self, _project: &Project) -> Result<(), Box> { + Ok(()) + } + + async fn delete(&self, id: &ProjectId) -> Result<(), Box> { + self.projects.lock().unwrap().remove(id); + Ok(()) + } + + async fn exists(&self, id: &ProjectId) -> Result> { + Ok(self.projects.lock().unwrap().contains(id)) + } + + async fn profile_exists( + &self, + _address: &WalletAddress, + ) -> Result> { + Ok(true) + } +} + +#[derive(Default)] +struct FakeLikeRepo { + likes: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl ProjectLikeRepository for FakeLikeRepo { + async fn create( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result> { + Ok(self + .likes + .lock() + .unwrap() + .insert((*project_id, user_address.as_str().to_string()))) + } + + async fn delete( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result> { + Ok(self + .likes + .lock() + .unwrap() + .remove(&(*project_id, user_address.as_str().to_string()))) + } + + async fn list_by_project( + &self, + project_id: &ProjectId, + limit: i64, + offset: i64, + ) -> Result, Box> { + let mut list: Vec = self + .likes + .lock() + .unwrap() + .iter() + .filter(|(pid, _)| pid == project_id) + .skip(offset as usize) + .take(limit as usize) + .map(|(pid, addr)| ProjectLike { + project_id: *pid, + user_address: WalletAddress(addr.clone()), + created_at: chrono::Utc::now(), + }) + .collect(); + list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(list) + } + + async fn count_by_project( + &self, + project_id: &ProjectId, + ) -> Result> { + Ok(self + .likes + .lock() + .unwrap() + .iter() + .filter(|(pid, _)| pid == project_id) + .count() as i64) + } + + async fn exists( + &self, + project_id: &ProjectId, + user_address: &WalletAddress, + ) -> Result> { + Ok(self.likes.lock().unwrap().contains(&( + *project_id, + user_address.as_str().to_string(), + ))) + } +} + +#[tokio::test] +async fn likes_crd_flow_works() { + let project_id = ProjectId::new(); + + let project_repo = Arc::new(FakeProjectRepo::default()); + project_repo.projects.lock().unwrap().insert(project_id); + + let app = test_api(AppState { + profile_repository: Arc::new(FakeProfileRepo::default()), + project_repository: project_repo, + project_like_repository: Arc::new(FakeLikeRepo::default()), + auth_service: Arc::new(FakeAuthService::default()), + }); + + let project_id_str = project_id.value().to_string(); + + // Create like + let create_req = Request::builder() + .method("POST") + .uri(format!("/projects/{}/likes", project_id_str)) + .header("x-eth-address", "0xABcdefabcdefabcdefabcdefabcdefabcdefabcdEF") + .body(Body::empty()) + .unwrap(); + let create_resp = app.clone().oneshot(create_req).await.unwrap(); + assert_eq!(create_resp.status(), StatusCode::CREATED); + + // Duplicate like -> conflict + let dup_req = Request::builder() + .method("POST") + .uri(format!("/projects/{}/likes", project_id_str)) + .header("x-eth-address", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef") + .body(Body::empty()) + .unwrap(); + let dup_resp = app.clone().oneshot(dup_req).await.unwrap(); + assert_eq!(dup_resp.status(), StatusCode::CONFLICT); + + // Read likes + let read_req = Request::builder() + .method("GET") + .uri(format!("/projects/{}/likes", project_id_str)) + .body(Body::empty()) + .unwrap(); + let read_resp = app.clone().oneshot(read_req).await.unwrap(); + assert_eq!(read_resp.status(), StatusCode::OK); + + // Delete like + let del_req = Request::builder() + .method("DELETE") + .uri(format!("/projects/{}/likes", project_id_str)) + .header("x-eth-address", "0xABCDEFabcdefabcdefabcdefabcdefabcdefabcdef") + .body(Body::empty()) + .unwrap(); + let del_resp = app.clone().oneshot(del_req).await.unwrap(); + assert_eq!(del_resp.status(), StatusCode::NO_CONTENT); + + // Delete again -> not found + let del2_req = Request::builder() + .method("DELETE") + .uri(format!("/projects/{}/likes", project_id_str)) + .header("x-eth-address", "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef") + .body(Body::empty()) + .unwrap(); + let del2_resp = app.oneshot(del2_req).await.unwrap(); + assert_eq!(del2_resp.status(), StatusCode::NOT_FOUND); +} +