diff --git a/src/http/project/domain.rs b/src/http/project/domain.rs index 714f99d5..68efcc5b 100644 --- a/src/http/project/domain.rs +++ b/src/http/project/domain.rs @@ -104,3 +104,71 @@ pub fn validate_bounty_expiry_date(date: &Option>, _context: &()) } Ok(()) } + +#[derive(Debug, Deserialize, Validate)] +pub struct ListProjectsQuery { + #[garde(custom(validate_starknet_address_optional))] + pub owner_address: Option, + #[garde(skip)] + pub active_only: Option, // Filter by closed_at being null + #[garde(skip)] + pub has_bounty: Option, // Filter by bounty_amount being not null + #[garde(custom(validate_sort_by))] + pub sort_by: Option, // "created_at" (default), "bounty_amount", "name" + #[garde(custom(validate_sort_order))] + pub sort_order: Option, // "desc" (default), "asc" + #[garde(range(min = 1, max = 20))] + pub limit: Option, // Max 20, default 10 + #[garde(skip)] + pub offset: Option, // Default 0 +} + +#[derive(Debug, Serialize)] +pub struct ProjectListItem { + pub id: Uuid, + pub name: String, + pub owner_address: String, + pub contract_address: String, + pub description: String, + pub is_verified: bool, + pub verification_date: Option>, + pub repository_url: Option, + pub bounty_amount: Option, + pub bounty_currency: Option, + pub bounty_expiry_date: Option>, + pub tags: Vec, + pub created_at: DateTime, + pub closed_at: Option>, +} + +#[derive(Debug, Serialize)] +pub struct ListProjectsResponse { + pub projects: Vec, + pub total_count: i64, + pub has_next: bool, +} + +pub fn validate_starknet_address_optional(addr: &Option, _context: &()) -> garde::Result { + if let Some(addr) = addr { + validate_starknet_address(addr, _context)?; + } + Ok(()) +} + +pub fn validate_sort_by(sort_by: &Option, _context: &()) -> garde::Result { + if let Some(sort_by) = sort_by { + if !["created_at", "bounty_amount", "name"].contains(&sort_by.as_str()) { + return Err(garde::Error::new("Invalid sort_by field")); + } + } + Ok(()) +} + +pub fn validate_sort_order(sort_order: &Option, _context: &()) -> garde::Result { + if let Some(sort_order) = sort_order { + if !["asc", "desc"].contains(&sort_order.as_str()) { + return Err(garde::Error::new("Invalid sort_order")); + } + } + Ok(()) +} diff --git a/src/http/project/list_projects.rs b/src/http/project/list_projects.rs new file mode 100644 index 00000000..fe737ba6 --- /dev/null +++ b/src/http/project/list_projects.rs @@ -0,0 +1,185 @@ +use crate::{AppState, Result}; +use crate::http::project::{ListProjectsQuery, ListProjectsResponse, ProjectListItem}; +use axum::{Json, extract::{Query, State}}; +use garde::Validate; +use sqlx::Row; + +#[tracing::instrument(name = "list_projects", skip(state))] +pub async fn list_projects_handler( + State(state): State, + Query(params): Query, +) -> Result> { + params.validate()?; + + let limit = params.limit.unwrap_or(10); + let offset = params.offset.unwrap_or(0); + let sort_by = params.sort_by.as_deref().unwrap_or("created_at"); + let sort_order = params.sort_order.as_deref().unwrap_or("desc"); + + // Build the ORDER BY clause + let order_by = match sort_by { + "bounty_amount" => format!("p.bounty_amount {} NULLS LAST", sort_order.to_uppercase()), + "name" => format!("p.name {}", sort_order.to_uppercase()), + _ => format!("p.created_at {}", sort_order.to_uppercase()), + }; + + // Build query based on filters + let (total_count_query, projects_query) = if let Some(_owner_address) = ¶ms.owner_address { + // Filter by owner address + let base_conditions = "WHERE p.owner_address = $1"; + let mut additional_conditions = Vec::new(); + + if params.active_only.unwrap_or(false) { + additional_conditions.push("p.closed_at IS NULL"); + } + + if params.has_bounty.unwrap_or(false) { + additional_conditions.push("p.bounty_amount IS NOT NULL"); + additional_conditions.push("p.closed_at IS NULL"); + } + + let where_clause = if additional_conditions.is_empty() { + base_conditions.to_string() + } else { + format!("{} AND {}", base_conditions, additional_conditions.join(" AND ")) + }; + + let count_query = format!( + "SELECT COUNT(*) FROM projects p {}", + where_clause + ); + + let projects_query = format!( + r#" + SELECT + p.id, p.name, p.owner_address, p.contract_address, p.description, + p.is_verified, p.verification_date, p.repository_url, p.bounty_amount, + p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at, + COALESCE( + array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL), + ARRAY[]::text[] + ) as tags + FROM projects p + LEFT JOIN project_tags pt ON p.id = pt.project_id + LEFT JOIN tags t ON pt.tag_id = t.id + {} + GROUP BY p.id, p.name, p.owner_address, p.contract_address, p.description, + p.is_verified, p.verification_date, p.repository_url, p.bounty_amount, + p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at + ORDER BY {} + LIMIT $2 OFFSET $3 + "#, + where_clause, order_by + ); + + (count_query, projects_query) + } else { + // No owner filter + let mut conditions = Vec::new(); + + if params.active_only.unwrap_or(false) { + conditions.push("p.closed_at IS NULL"); + } + + if params.has_bounty.unwrap_or(false) { + conditions.push("p.bounty_amount IS NOT NULL"); + conditions.push("p.closed_at IS NULL"); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let count_query = format!( + "SELECT COUNT(*) FROM projects p {}", + where_clause + ); + + let projects_query = format!( + r#" + SELECT + p.id, p.name, p.owner_address, p.contract_address, p.description, + p.is_verified, p.verification_date, p.repository_url, p.bounty_amount, + p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at, + COALESCE( + array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL), + ARRAY[]::text[] + ) as tags + FROM projects p + LEFT JOIN project_tags pt ON p.id = pt.project_id + LEFT JOIN tags t ON pt.tag_id = t.id + {} + GROUP BY p.id, p.name, p.owner_address, p.contract_address, p.description, + p.is_verified, p.verification_date, p.repository_url, p.bounty_amount, + p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at + ORDER BY {} + LIMIT $1 OFFSET $2 + "#, + where_clause, order_by + ); + + (count_query, projects_query) + }; + + // Execute count query + let total_count: i64 = if let Some(owner_address) = ¶ms.owner_address { + sqlx::query_scalar(&total_count_query) + .bind(owner_address) + .fetch_one(&state.db.pool) + .await? + } else { + sqlx::query_scalar(&total_count_query) + .fetch_one(&state.db.pool) + .await? + }; + + // Execute projects query + let rows = if let Some(owner_address) = ¶ms.owner_address { + sqlx::query(&projects_query) + .bind(owner_address) + .bind(limit) + .bind(offset) + .fetch_all(&state.db.pool) + .await? + } else { + sqlx::query(&projects_query) + .bind(limit) + .bind(offset) + .fetch_all(&state.db.pool) + .await? + }; + + let projects: Vec = rows + .into_iter() + .map(|row| { + let tags: Vec = row.get::, _>("tags"); + + ProjectListItem { + id: row.get("id"), + name: row.get("name"), + owner_address: row.get("owner_address"), + contract_address: row.get("contract_address"), + description: row.get("description"), + is_verified: row.get("is_verified"), + verification_date: row.get("verification_date"), + repository_url: row.get("repository_url"), + bounty_amount: row.get("bounty_amount"), + bounty_currency: row.get("bounty_currency"), + bounty_expiry_date: row.get("bounty_expiry_date"), + tags, + created_at: row.get("created_at"), + closed_at: row.get("closed_at"), + } + }) + .collect(); + + let has_next = (offset + limit) < total_count; + + Ok(Json(ListProjectsResponse { + projects, + total_count, + has_next, + })) +} diff --git a/src/http/project/mod.rs b/src/http/project/mod.rs index 408fa4ba..ffd9cadb 100644 --- a/src/http/project/mod.rs +++ b/src/http/project/mod.rs @@ -1,6 +1,7 @@ mod close_project; mod create_project; mod domain; +mod list_projects; mod project_detail_view; mod shared; mod verify_project; @@ -31,4 +32,8 @@ pub(crate) fn router() -> Router { "/projects/{project_id}", get(project_detail_view::get_project_detail_view), ) + .route( + "/projects", + get(list_projects::list_projects_handler), + ) } diff --git a/tests/api/list_projects.rs b/tests/api/list_projects.rs new file mode 100644 index 00000000..5ab733f2 --- /dev/null +++ b/tests/api/list_projects.rs @@ -0,0 +1,441 @@ +use crate::helpers::{TestApp, generate_address}; +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use bigdecimal::BigDecimal; +use uuid::Uuid; + +async fn create_test_project( + app: &TestApp, + name: &str, + owner_address: &str, + bounty_amount: Option, + closed_at: Option>, + tags: Vec<&str>, +) -> Uuid { + sqlx::query!( + "INSERT INTO escrow_users (wallet_address) VALUES ($1) ON CONFLICT (wallet_address) DO NOTHING", + owner_address + ) + .execute(&app.db.pool) + .await + .unwrap(); + + let project_id = sqlx::query_scalar!( + r#" + INSERT INTO projects ( + owner_address, contract_address, name, description, contact_info, + bounty_amount, bounty_currency, bounty_expiry_date, closed_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 + ) RETURNING id + "#, + owner_address, + &generate_address(), + name, + "A test project description that meets the minimum length requirement.", + "contact@example.com", + bounty_amount, + if bounty_amount.is_some() { Some("STRK") } else { None }, + if bounty_amount.is_some() { Some(chrono::Utc::now() + chrono::Duration::days(30)) } else { None }, + closed_at + ) + .fetch_one(&app.db.pool) + .await + .unwrap(); + + // Add tags if provided + if !tags.is_empty() { + for tag in tags { + let tag_id = sqlx::query_scalar!( + "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id", + tag + ) + .fetch_one(&app.db.pool) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + project_id, + tag_id + ) + .execute(&app.db.pool) + .await + .unwrap(); + } + } + + project_id +} + +#[tokio::test] +async fn test_list_projects_success() { + let app = TestApp::new().await; + let owner1 = generate_address(); + let owner2 = generate_address(); + + // Create test projects + create_test_project(&app, "Project Alpha", &owner1, Some(BigDecimal::from(1000)), None, vec!["DeFi", "Audit"]).await; + create_test_project(&app, "Project Beta", &owner2, None, None, vec!["NFT"]).await; + create_test_project(&app, "Project Gamma", &owner1, Some(BigDecimal::from(500)), Some(chrono::Utc::now()), vec![]).await; + + let req = Request::get("/projects") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 3); + assert_eq!(response["total_count"], 3); + assert_eq!(response["has_next"], false); + + // Verify projects are sorted by created_at desc by default + let projects = response["projects"].as_array().unwrap(); + let project_names: Vec<&str> = projects + .iter() + .map(|p| p["name"].as_str().unwrap()) + .collect(); + + // The most recently created should be first + assert_eq!(project_names[0], "Project Gamma"); +} + +#[tokio::test] +async fn test_list_projects_filter_by_owner() { + let app = TestApp::new().await; + let owner1 = generate_address(); + let owner2 = generate_address(); + + create_test_project(&app, "Project Alpha", &owner1, None, None, vec![]).await; + create_test_project(&app, "Project Beta", &owner2, None, None, vec![]).await; + create_test_project(&app, "Project Gamma", &owner1, None, None, vec![]).await; + + let req = Request::get(&format!("/projects?owner_address={}", owner1)) + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 2); + assert_eq!(response["total_count"], 2); + + // Verify all projects belong to owner1 + for project in response["projects"].as_array().unwrap() { + assert_eq!(project["owner_address"], owner1); + } +} + +#[tokio::test] +async fn test_list_projects_filter_active_only() { + let app = TestApp::new().await; + let owner = generate_address(); + + create_test_project(&app, "Active Project", &owner, None, None, vec![]).await; + create_test_project(&app, "Closed Project", &owner, None, Some(chrono::Utc::now()), vec![]).await; + + let req = Request::get("/projects?active_only=true") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 1); + assert_eq!(response["total_count"], 1); + assert_eq!(response["projects"][0]["name"], "Active Project"); + assert!(response["projects"][0]["closed_at"].is_null()); +} + +#[tokio::test] +async fn test_list_projects_filter_has_bounty() { + let app = TestApp::new().await; + let owner = generate_address(); + + create_test_project(&app, "Project with Bounty", &owner, Some(BigDecimal::from(1000)), None, vec![]).await; + create_test_project(&app, "Project without Bounty", &owner, None, None, vec![]).await; + create_test_project(&app, "Closed Project with Bounty", &owner, Some(BigDecimal::from(500)), Some(chrono::Utc::now()), vec![]).await; + + let req = Request::get("/projects?has_bounty=true") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + // Should only return active projects with bounties + assert_eq!(response["projects"].as_array().unwrap().len(), 1); + assert_eq!(response["total_count"], 1); + assert_eq!(response["projects"][0]["name"], "Project with Bounty"); + assert!(!response["projects"][0]["bounty_amount"].is_null()); + assert!(response["projects"][0]["closed_at"].is_null()); +} + +#[tokio::test] +async fn test_list_projects_sort_by_name() { + let app = TestApp::new().await; + let owner = generate_address(); + + create_test_project(&app, "Zebra Project", &owner, None, None, vec![]).await; + create_test_project(&app, "Alpha Project", &owner, None, None, vec![]).await; + create_test_project(&app, "Beta Project", &owner, None, None, vec![]).await; + + let req = Request::get("/projects?sort_by=name&sort_order=asc") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + let project_names: Vec<&str> = response["projects"] + .as_array() + .unwrap() + .iter() + .map(|p| p["name"].as_str().unwrap()) + .collect(); + + assert_eq!(project_names, vec!["Alpha Project", "Beta Project", "Zebra Project"]); +} + +#[tokio::test] +async fn test_list_projects_sort_by_bounty_amount() { + let app = TestApp::new().await; + let owner = generate_address(); + + create_test_project(&app, "High Bounty", &owner, Some(BigDecimal::from(2000)), None, vec![]).await; + create_test_project(&app, "Low Bounty", &owner, Some(BigDecimal::from(500)), None, vec![]).await; + create_test_project(&app, "No Bounty", &owner, None, None, vec![]).await; + + let req = Request::get("/projects?sort_by=bounty_amount&sort_order=desc") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + let project_names: Vec<&str> = response["projects"] + .as_array() + .unwrap() + .iter() + .map(|p| p["name"].as_str().unwrap()) + .collect(); + + // Projects with bounties should come first, sorted by amount desc, then null bounties + assert_eq!(project_names[0], "High Bounty"); + assert_eq!(project_names[1], "Low Bounty"); + assert_eq!(project_names[2], "No Bounty"); +} + +#[tokio::test] +async fn test_list_projects_pagination() { + let app = TestApp::new().await; + let owner = generate_address(); + + // Create 5 projects + for i in 1..=5 { + create_test_project(&app, &format!("Project {}", i), &owner, None, None, vec![]).await; + } + + // Test first page + let req = Request::get("/projects?limit=2&offset=0") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 2); + assert_eq!(response["total_count"], 5); + assert_eq!(response["has_next"], true); + + // Test second page + let req = Request::get("/projects?limit=2&offset=2") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 2); + assert_eq!(response["total_count"], 5); + assert_eq!(response["has_next"], true); + + // Test last page + let req = Request::get("/projects?limit=2&offset=4") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 1); + assert_eq!(response["total_count"], 5); + assert_eq!(response["has_next"], false); +} + +#[tokio::test] +async fn test_list_projects_validation_errors() { + let app = TestApp::new().await; + + // Test invalid owner address + let req = Request::get("/projects?owner_address=invalid") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // Test invalid sort_by + let req = Request::get("/projects?sort_by=invalid_field") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // Test invalid sort_order + let req = Request::get("/projects?sort_order=invalid") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // Test limit too high + let req = Request::get("/projects?limit=25") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // Test limit too low + let req = Request::get("/projects?limit=0") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_list_projects_includes_tags() { + let app = TestApp::new().await; + let owner = generate_address(); + + create_test_project(&app, "Tagged Project", &owner, None, None, vec!["DeFi", "Security", "StarkNet"]).await; + create_test_project(&app, "Untagged Project", &owner, None, None, vec![]).await; + + let req = Request::get("/projects") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + let projects = response["projects"].as_array().unwrap(); + + // Find the tagged project + let tagged_project = projects + .iter() + .find(|p| p["name"] == "Tagged Project") + .unwrap(); + + let tags = tagged_project["tags"].as_array().unwrap(); + assert_eq!(tags.len(), 3); + + let tag_names: Vec<&str> = tags + .iter() + .map(|t| t.as_str().unwrap()) + .collect(); + + // Tags should be sorted alphabetically + assert_eq!(tag_names, vec!["DeFi", "Security", "StarkNet"]); + + // Find the untagged project + let untagged_project = projects + .iter() + .find(|p| p["name"] == "Untagged Project") + .unwrap(); + + let empty_tags = untagged_project["tags"].as_array().unwrap(); + assert_eq!(empty_tags.len(), 0); +} + +#[tokio::test] +async fn test_list_projects_empty_result() { + let app = TestApp::new().await; + + let req = Request::get("/projects") + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 0); + assert_eq!(response["total_count"], 0); + assert_eq!(response["has_next"], false); +} + +#[tokio::test] +async fn test_list_projects_combined_filters() { + let app = TestApp::new().await; + let owner1 = generate_address(); + let owner2 = generate_address(); + + // Create test scenarios + create_test_project(&app, "Owner1 Active Bounty", &owner1, Some(BigDecimal::from(1000)), None, vec![]).await; + create_test_project(&app, "Owner1 Active No Bounty", &owner1, None, None, vec![]).await; + create_test_project(&app, "Owner1 Closed Bounty", &owner1, Some(BigDecimal::from(500)), Some(chrono::Utc::now()), vec![]).await; + create_test_project(&app, "Owner2 Active Bounty", &owner2, Some(BigDecimal::from(2000)), None, vec![]).await; + + // Test owner + has_bounty filters + let req = Request::get(&format!("/projects?owner_address={}&has_bounty=true", owner1)) + .body(Body::empty()) + .unwrap(); + let res = app.request(req).await; + + assert_eq!(res.status(), StatusCode::OK); + + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(response["projects"].as_array().unwrap().len(), 1); + assert_eq!(response["total_count"], 1); + assert_eq!(response["projects"][0]["name"], "Owner1 Active Bounty"); + assert_eq!(response["projects"][0]["owner_address"], owner1); + assert!(!response["projects"][0]["bounty_amount"].is_null()); + assert!(response["projects"][0]["closed_at"].is_null()); +} diff --git a/tests/api/main.rs b/tests/api/main.rs index 86da4f78..33160bb1 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -3,6 +3,7 @@ mod create_project; mod escrow; mod health_check; mod helpers; +mod list_projects; mod newsletter; mod projects; mod support_tickets;