diff --git a/anon/backend/.sqlx/query-613dec2d46124070791f50ad38f90a5907a5195552b9f9dc4a971d792e0e1362.json b/anon/backend/.sqlx/query-613dec2d46124070791f50ad38f90a5907a5195552b9f9dc4a971d792e0e1362.json deleted file mode 100644 index 6fa29482..00000000 --- a/anon/backend/.sqlx/query-613dec2d46124070791f50ad38f90a5907a5195552b9f9dc4a971d792e0e1362.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM generated_contracts", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "613dec2d46124070791f50ad38f90a5907a5195552b9f9dc4a971d792e0e1362" -} diff --git a/anon/backend/.sqlx/query-6b7ecd5e7c48f4bb8e7af37eca945d527d6c3ee503d73906648dd4a94e718427.json b/anon/backend/.sqlx/query-6b7ecd5e7c48f4bb8e7af37eca945d527d6c3ee503d73906648dd4a94e718427.json deleted file mode 100644 index 1d0d8a38..00000000 --- a/anon/backend/.sqlx/query-6b7ecd5e7c48f4bb8e7af37eca945d527d6c3ee503d73906648dd4a94e718427.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM generated_contracts WHERE user_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "6b7ecd5e7c48f4bb8e7af37eca945d527d6c3ee503d73906648dd4a94e718427" -} diff --git a/anon/backend/.sqlx/query-949a44457416ff9332fea3f100f3d1c7091d6c9a577990afde278605acf6a849.json b/anon/backend/.sqlx/query-949a44457416ff9332fea3f100f3d1c7091d6c9a577990afde278605acf6a849.json deleted file mode 100644 index cdc925ac..00000000 --- a/anon/backend/.sqlx/query-949a44457416ff9332fea3f100f3d1c7091d6c9a577990afde278605acf6a849.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM profiles", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "949a44457416ff9332fea3f100f3d1c7091d6c9a577990afde278605acf6a849" -} diff --git a/anon/backend/.sqlx/query-982d51b229178b33575c9deb29fc7fe444fd9f611669c1b512e6cdf246f5664f.json b/anon/backend/.sqlx/query-98530150563f181667dd6e026550ff9ba4af569def2b709aaf12796d12b8f85a.json similarity index 78% rename from anon/backend/.sqlx/query-982d51b229178b33575c9deb29fc7fe444fd9f611669c1b512e6cdf246f5664f.json rename to anon/backend/.sqlx/query-98530150563f181667dd6e026550ff9ba4af569def2b709aaf12796d12b8f85a.json index d8c0020a..b648970e 100644 --- a/anon/backend/.sqlx/query-982d51b229178b33575c9deb29fc7fe444fd9f611669c1b512e6cdf246f5664f.json +++ b/anon/backend/.sqlx/query-98530150563f181667dd6e026550ff9ba4af569def2b709aaf12796d12b8f85a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO generated_contracts (\n user_id, contract_type, contract_name, description, \n parameters, template_id, generated_code, status\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING \n id, user_id, contract_type, contract_name, description,\n parameters, template_id, generated_code, status, created_at, updated_at\n ", + "query": "\n INSERT INTO generated_contracts (\n user_id, contract_type, contract_name, description,\n parameters, template_id, generated_code, status\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING\n id, user_id, contract_type, contract_name, description,\n parameters, template_id, generated_code, status, created_at, updated_at\n ", "describe": { "columns": [ { @@ -85,5 +85,5 @@ false ] }, - "hash": "982d51b229178b33575c9deb29fc7fe444fd9f611669c1b512e6cdf246f5664f" + "hash": "98530150563f181667dd6e026550ff9ba4af569def2b709aaf12796d12b8f85a" } diff --git a/anon/backend/.sqlx/query-ac8874076611d27bb4aae32698288d452dd9b6449d02850bd2679b5ec1cf1b3c.json b/anon/backend/.sqlx/query-ac8874076611d27bb4aae32698288d452dd9b6449d02850bd2679b5ec1cf1b3c.json deleted file mode 100644 index dbd3f2e5..00000000 --- a/anon/backend/.sqlx/query-ac8874076611d27bb4aae32698288d452dd9b6449d02850bd2679b5ec1cf1b3c.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT * FROM generated_contracts WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "contract_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "contract_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "parameters", - "type_info": "Jsonb" - }, - { - "ordinal": 6, - "name": "template_id", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "generated_code", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - true, - false, - false, - false, - false - ] - }, - "hash": "ac8874076611d27bb4aae32698288d452dd9b6449d02850bd2679b5ec1cf1b3c" -} diff --git a/anon/backend/.sqlx/query-b707709a22a18789f9a669e08eaedfffd266dfc74473001b257bf99c12fd99b2.json b/anon/backend/.sqlx/query-b707709a22a18789f9a669e08eaedfffd266dfc74473001b257bf99c12fd99b2.json deleted file mode 100644 index f21de1d5..00000000 --- a/anon/backend/.sqlx/query-b707709a22a18789f9a669e08eaedfffd266dfc74473001b257bf99c12fd99b2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n TRUNCATE TABLE\n generated_contracts,\n profiles,\n users\n RESTART IDENTITY CASCADE\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "b707709a22a18789f9a669e08eaedfffd266dfc74473001b257bf99c12fd99b2" -} diff --git a/anon/backend/.sqlx/query-ed764fe4aaac05e73a8d7510edbf72b32aca5aa9db98af80eb6699523b371fe2.json b/anon/backend/.sqlx/query-ed764fe4aaac05e73a8d7510edbf72b32aca5aa9db98af80eb6699523b371fe2.json deleted file mode 100644 index 7e89112f..00000000 --- a/anon/backend/.sqlx/query-ed764fe4aaac05e73a8d7510edbf72b32aca5aa9db98af80eb6699523b371fe2.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users (wallet) VALUES ($1) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "ed764fe4aaac05e73a8d7510edbf72b32aca5aa9db98af80eb6699523b371fe2" -} diff --git a/anon/backend/.sqlx/query-f4f8f8c2668ec23ba1f4a315d74087521496603e8b1bc10475a864001e795593.json b/anon/backend/.sqlx/query-f4f8f8c2668ec23ba1f4a315d74087521496603e8b1bc10475a864001e795593.json deleted file mode 100644 index 68dd7533..00000000 --- a/anon/backend/.sqlx/query-f4f8f8c2668ec23ba1f4a315d74087521496603e8b1bc10475a864001e795593.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM users", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "f4f8f8c2668ec23ba1f4a315d74087521496603e8b1bc10475a864001e795593" -} diff --git a/anon/backend/Cargo.lock b/anon/backend/Cargo.lock index 8cd1d7e8..5f3fc661 100644 --- a/anon/backend/Cargo.lock +++ b/anon/backend/Cargo.lock @@ -4061,9 +4061,16 @@ dependencies = [ "serde_json", "url", "utoipa", + "utoipa-swagger-ui-vendored", "zip", ] +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "0.8.2" diff --git a/anon/backend/src/lib.rs b/anon/backend/src/lib.rs index a7bf889b..1a748d53 100644 --- a/anon/backend/src/lib.rs +++ b/anon/backend/src/lib.rs @@ -50,6 +50,10 @@ pub fn create_app(state: AppState) -> Router { .route("/register", post(routes::register::register)) .route("/user", get(routes::user::me)) .route("/generate", post(routes::generate::generate_contract)) + .route( + "/generated_contracts", + get(routes::generate::list_generated_contracts), + ) .route("/reviews", get(routes::reviews::list_reviews)) .route("/health", get(routes::health::health)) // Swagger UI at /docs and OpenAPI JSON at /api-docs/openapi.json diff --git a/anon/backend/src/libs/apispec.rs b/anon/backend/src/libs/apispec.rs index 478c5941..1f7ce183 100644 --- a/anon/backend/src/libs/apispec.rs +++ b/anon/backend/src/libs/apispec.rs @@ -25,7 +25,10 @@ impl Modify for SecurityAddon { paths( crate::routes::register::register, crate::routes::user::me, - crate::routes::health::healthz + crate::routes::health::healthz, + crate::routes::generate::generate_contract, + crate::routes::generate::list_generated_contracts, + crate::routes::reviews::list_reviews ), components( schemas( @@ -34,13 +37,23 @@ impl Modify for SecurityAddon { crate::routes::user::UserMeRes, crate::routes::user::ProfilePublic, crate::libs::error::ErrorBody, - crate::routes::health::HealthzResponse + crate::routes::health::HealthzResponse, + // Contracts + crate::routes::generate::GenerateContractReq, + crate::routes::generate::GenerateContractRes, + crate::routes::generate::GeneratedContractItem, + crate::routes::generate::GeneratedContractsListRes, + // Reviews + crate::routes::reviews::ReviewItem, + crate::routes::reviews::ReviewsListRes ) ), modifiers(&SecurityAddon), tags( (name = "health", description = "Health check endpoints"), - (name = "auth", description = "Authentication & registration endpoints") + (name = "auth", description = "Authentication & registration endpoints"), + (name = "contracts", description = "Generated contracts endpoints"), + (name = "reviews", description = "Reviews listing endpoints") ) )] pub struct ApiDoc; diff --git a/anon/backend/src/main.rs b/anon/backend/src/main.rs index 750a7e50..64df9e8f 100644 --- a/anon/backend/src/main.rs +++ b/anon/backend/src/main.rs @@ -51,6 +51,10 @@ async fn main() { .route("/register", post(routes::register::register)) .route("/user", get(routes::user::me)) .route("/generate", post(routes::generate::generate_contract)) + .route( + "/generated_contracts", + get(routes::generate::list_generated_contracts), + ) .route("/reviews", get(routes::reviews::list_reviews)) // Swagger UI at /docs and OpenAPI JSON at /api-docs/openapi.json .merge(SwaggerUi::new("/docs").url( diff --git a/anon/backend/src/routes/generate.rs b/anon/backend/src/routes/generate.rs index 31381838..e2345a6c 100644 --- a/anon/backend/src/routes/generate.rs +++ b/anon/backend/src/routes/generate.rs @@ -1,10 +1,33 @@ -use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing; use utoipa::ToSchema; use crate::libs::{db::AppState, error::ApiError}; +use crate::middlewares::auth::AuthUser; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GeneratedContractsCursor { + pub created_at: DateTime, + pub id: i64, +} + +pub fn encode_cursor(cursor: &GeneratedContractsCursor) -> String { + let json = serde_json::to_vec(cursor).expect("cursor json"); + URL_SAFE_NO_PAD.encode(json) +} + +pub fn decode_cursor(s: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(s).ok()?; + serde_json::from_slice(&bytes).ok() +} #[derive(Deserialize, ToSchema)] pub struct GenerateContractReq { @@ -31,6 +54,32 @@ pub struct GenerateContractRes { pub updated_at: DateTime, } +#[derive(Deserialize, ToSchema, utoipa::IntoParams)] +pub struct GeneratedContractsQuery { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct GeneratedContractItem { + pub id: i64, + pub user_id: i64, + pub contract_type: String, + pub contract_name: String, + pub description: Option, + pub parameters: Option, + pub template_id: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Serialize, ToSchema)] +pub struct GeneratedContractsListRes { + pub items: Vec, + pub next_cursor: Option, +} + /// Generate a new contract for a user #[utoipa::path( post, @@ -141,10 +190,10 @@ mod {} {{ let rec = sqlx::query!( r#" INSERT INTO generated_contracts ( - user_id, contract_type, contract_name, description, + user_id, contract_type, contract_name, description, parameters, template_id, generated_code, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING + RETURNING id, user_id, contract_type, contract_name, description, parameters, template_id, generated_code, status, created_at, updated_at "#, @@ -187,3 +236,135 @@ mod {} {{ }), )) } + +#[utoipa::path( + get, + path = "/generated_contracts", + tag = "contracts", + security(("bearer_auth" = [])), + params(GeneratedContractsQuery), + responses( + (status = 200, description = "List of generated contracts", body = GeneratedContractsListRes), + (status = 401, description = "Unauthorized", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn list_generated_contracts( + State(AppState { pool }): State, + AuthUser { wallet }: AuthUser, + Query(q): Query, +) -> Result, ApiError> { + let limit = q.limit.unwrap_or(20).clamp(1, 50); + + // Decode cursor if provided + let cursor = match q.cursor.as_deref() { + Some(s) => decode_cursor(s), + None => None, + }; + + // Get user ID from wallet + let user_id: (i64,) = sqlx::query_as("SELECT id FROM users WHERE wallet = $1") + .bind(&wallet) + .fetch_optional(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))? + .ok_or(ApiError::NotFound("user not found"))?; + + // Build query for generated contracts + let rows = if let Some(c) = &cursor { + sqlx::query_as::<_, ( + i64, // id + i64, // user_id + String, // contract_type + String, // contract_name + Option, // description + Option, // parameters + Option, // template_id + String, // status + DateTime, // created_at + DateTime, // updated_at + )>( + r#"SELECT id, user_id, contract_type, contract_name, description, parameters, template_id, status, created_at, updated_at + FROM generated_contracts + WHERE user_id = $1 AND (created_at < $2 OR (created_at = $2 AND id < $3)) + ORDER BY created_at DESC, id DESC + LIMIT $4"# + ) + .bind(user_id.0) + .bind(c.created_at) // $2 + .bind(c.id) // $3 + .bind(limit) // $4 + .fetch_all(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))? + } else { + sqlx::query_as::<_, ( + i64, + i64, + String, + String, + Option, + Option, + Option, + String, + DateTime, + DateTime, + )>( + r#"SELECT id, user_id, contract_type, contract_name, description, parameters, template_id, status, created_at, updated_at + FROM generated_contracts + WHERE user_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2"# + ) + .bind(user_id.0) + .bind(limit) + .fetch_all(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))? + }; + + let items: Vec = rows + .into_iter() + .map( + |( + id, + user_id, + contract_type, + contract_name, + description, + parameters, + template_id, + status, + created_at, + updated_at, + )| { + GeneratedContractItem { + id, + user_id, + contract_type, + contract_name, + description, + parameters, + template_id, + status, + created_at, + updated_at, + } + }, + ) + .collect(); + + let next_cursor = if items.len() as i64 == limit { + items.last().map(|last| { + let c = GeneratedContractsCursor { + created_at: last.created_at, + id: last.id, + }; + encode_cursor(&c) + }) + } else { + None + }; + + Ok(Json(GeneratedContractsListRes { items, next_cursor })) +} diff --git a/anon/backend/tests/generate_contract_test.rs b/anon/backend/tests/generate_contract_test.rs index 91eaec23..e78dc083 100644 --- a/anon/backend/tests/generate_contract_test.rs +++ b/anon/backend/tests/generate_contract_test.rs @@ -5,6 +5,20 @@ use sqlx::PgPool; use backend::libs::db::AppState; +// Keep tuple shapes readable in tests (addresses clippy::type_complexity) +type GeneratedContractRow = ( + i64, // id + i64, // user_id + String, // contract_type + String, // contract_name + Option, // description + Option, // parameters + Option, // template_id + String, // status + chrono::DateTime, // created_at + chrono::DateTime, // updated_at +); + // Test helper to create a test server async fn create_test_server() -> (TestServer, PgPool) { // Use test database URL from environment or default @@ -36,31 +50,28 @@ async fn create_test_user(pool: &PgPool) -> i64 { .unwrap() .as_nanos(); - let wallet = format!("0x{:040x}", timestamp); + let raw_wallet = format!("0x{:040x}", timestamp); + let wallet = backend::libs::wallet::normalize_and_validate(&raw_wallet) + .expect("Failed to normalize wallet"); - let user = sqlx::query!( - "INSERT INTO users (wallet) VALUES ($1) RETURNING id", - wallet - ) - .fetch_one(pool) - .await - .expect("Failed to create test user"); + let user_id: (i64,) = sqlx::query_as("INSERT INTO users (wallet) VALUES ($1) RETURNING id") + .bind(&wallet) + .fetch_one(pool) + .await + .expect("Failed to create test user"); - user.id + user_id.0 } // Test helper to clean up test data async fn cleanup_test_data(pool: &PgPool) { // Delete in correct order due to foreign key constraints - sqlx::query!("DELETE FROM generated_contracts") + sqlx::query("DELETE FROM generated_contracts") .execute(pool) .await .ok(); - sqlx::query!("DELETE FROM profiles") - .execute(pool) - .await - .ok(); - sqlx::query!("DELETE FROM users").execute(pool).await.ok(); + sqlx::query("DELETE FROM profiles").execute(pool).await.ok(); + sqlx::query("DELETE FROM users").execute(pool).await.ok(); } #[tokio::test] @@ -98,17 +109,17 @@ async fn test_generate_contract_success() { assert!(response_body["contract_id"].as_i64().unwrap() > 0); // Verify data was persisted in database - let db_contract = sqlx::query!( - "SELECT * FROM generated_contracts WHERE id = $1", - response_body["contract_id"].as_i64().unwrap() + let db_contract: GeneratedContractRow = sqlx::query_as( + "SELECT id, user_id, contract_type, contract_name, description, parameters, template_id, status, created_at, updated_at FROM generated_contracts WHERE id = $1" ) + .bind(response_body["contract_id"].as_i64().unwrap()) .fetch_one(&pool) .await .expect("Contract should exist in database"); - assert_eq!(db_contract.user_id, user_id); - assert_eq!(db_contract.contract_type, "token"); - assert_eq!(db_contract.contract_name, "MyToken"); + assert_eq!(db_contract.1, user_id); // user_id + assert_eq!(db_contract.2, "token"); // contract_type + assert_eq!(db_contract.3, "MyToken"); // contract_name cleanup_test_data(&pool).await; } @@ -393,15 +404,14 @@ async fn test_generate_contract_multiple_contracts_same_user() { assert_eq!(contract_1["user_id"], user_id); // Verify both contracts are in database - let contract_count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM generated_contracts WHERE user_id = $1", - user_id - ) - .fetch_one(&pool) - .await - .expect("Should count contracts"); + let contract_count: (Option,) = + sqlx::query_as("SELECT COUNT(*) FROM generated_contracts WHERE user_id = $1") + .bind(user_id) + .fetch_one(&pool) + .await + .expect("Should count contracts"); - assert_eq!(contract_count, Some(2)); + assert_eq!(contract_count.0, Some(2)); cleanup_test_data(&pool).await; } @@ -449,7 +459,7 @@ async fn test_generate_contract_invalid_json() { #[tokio::test] async fn test_generate_contract_missing_user_id() { - let (server, _pool) = create_test_server().await; + let (server, pool) = create_test_server().await; let request_body = json!({ "contract_type": "token", @@ -460,4 +470,360 @@ async fn test_generate_contract_missing_user_id() { // This should fail due to missing user_id field assert_eq!(response.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + + cleanup_test_data(&pool).await; +} + +// Test helper to create JWT token for a wallet +fn create_jwt_token(wallet: &str) -> String { + let secret = backend::libs::jwt::secret_from_env(); + backend::libs::jwt::encode(wallet, &secret).unwrap() +} + +// Test helper to create a test user with profile +async fn create_test_user_with_profile(pool: &PgPool) -> (i64, String) { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + + let raw_wallet = format!("0x{:040x}", timestamp); + let wallet = backend::libs::wallet::normalize_and_validate(&raw_wallet) + .expect("Failed to normalize wallet"); + + let user_id: (i64,) = sqlx::query_as("INSERT INTO users (wallet) VALUES ($1) RETURNING id") + .bind(&wallet) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + // Create profile + sqlx::query("INSERT INTO profiles (user_id, referral_code) VALUES ($1, $2)") + .bind(user_id.0) + .bind(format!("REF{}", user_id.0)) + .execute(pool) + .await + .ok(); + + (user_id.0, wallet) +} + +#[tokio::test] +async fn test_list_generated_contracts_success() { + let (server, pool) = create_test_server().await; + let (user_id, wallet) = create_test_user_with_profile(&pool).await; + + // Create some test contracts + let contract_data = vec![ + ( + "token", + "MyToken", + Some("A test token"), + Some(json!({"symbol": "MTK"})), + ), + ("nft", "MyNFT", None, None), + ( + "custom", + "CustomContract", + Some("Custom implementation"), + Some(json!({"version": "1.0"})), + ), + ]; + + let mut created_contracts = Vec::new(); + + for (contract_type, contract_name, description, parameters) in contract_data { + let request_body = json!({ + "user_id": user_id, + "contract_type": contract_type, + "contract_name": contract_name, + "description": description, + "parameters": parameters + }); + + let response = server.post("/generate").json(&request_body).await; + assert_eq!(response.status_code(), StatusCode::CREATED); + + let contract: Value = response.json(); + created_contracts.push(contract); + } + + // Test listing contracts + let token = create_jwt_token(&wallet); + let response = server + .get("/generated_contracts") + .authorization_bearer(&token) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + + let response_body: Value = response.json(); + + // Verify response structure + assert!(response_body["items"].is_array()); + let items = response_body["items"].as_array().unwrap(); + assert_eq!(items.len(), 3); + + // Verify contracts are returned in descending order by created_at + for (i, item) in items.iter().enumerate() { + assert_eq!(item["user_id"], user_id); + assert_eq!( + item["contract_type"], + created_contracts[2 - i]["contract_type"] + ); + assert_eq!( + item["contract_name"], + created_contracts[2 - i]["contract_name"] + ); + assert!(item["created_at"].is_string()); + assert!(item["updated_at"].is_string()); + } + + // Verify next_cursor is None when all items fit + assert!(response_body["next_cursor"].is_null()); + + cleanup_test_data(&pool).await; +} + +#[tokio::test] +async fn test_list_generated_contracts_empty() { + let (server, pool) = create_test_server().await; + let (_user_id, wallet) = create_test_user_with_profile(&pool).await; + + // Test listing contracts when user has none + let token = create_jwt_token(&wallet); + let response = server + .get("/generated_contracts") + .authorization_bearer(&token) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + + let response_body: Value = response.json(); + + // Verify empty response + assert!(response_body["items"].is_array()); + let items = response_body["items"].as_array().unwrap(); + assert_eq!(items.len(), 0); + assert!(response_body["next_cursor"].is_null()); + + cleanup_test_data(&pool).await; +} + +#[tokio::test] +async fn test_list_generated_contracts_pagination() { + let (server, pool) = create_test_server().await; + let (user_id, wallet) = create_test_user_with_profile(&pool).await; + + // Create 5 test contracts + for i in 0..5 { + let request_body = json!({ + "user_id": user_id, + "contract_type": "token", + "contract_name": format!("Token{}", i) + }); + + let response = server.post("/generate").json(&request_body).await; + assert_eq!(response.status_code(), StatusCode::CREATED); + } + + // Test pagination with limit 2 + let token = create_jwt_token(&wallet); + let response = server + .get("/generated_contracts?limit=2") + .authorization_bearer(&token) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + + let response_body: Value = response.json(); + let items = response_body["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert!(response_body["next_cursor"].is_string()); + + // Test using cursor for next page + let next_cursor = response_body["next_cursor"].as_str().unwrap(); + let response2 = server + .get(&format!( + "/generated_contracts?limit=2&cursor={}", + next_cursor + )) + .authorization_bearer(&token) + .await; + + assert_eq!(response2.status_code(), StatusCode::OK); + + let response_body2: Value = response2.json(); + let items2 = response_body2["items"].as_array().unwrap(); + assert_eq!(items2.len(), 2); + + // Verify no overlap between pages + let first_page_ids: Vec = items + .iter() + .map(|item| item["id"].as_i64().unwrap()) + .collect(); + let second_page_ids: Vec = items2 + .iter() + .map(|item| item["id"].as_i64().unwrap()) + .collect(); + + for id1 in &first_page_ids { + assert!(!second_page_ids.contains(id1)); + } + + cleanup_test_data(&pool).await; +} + +#[tokio::test] +async fn test_list_generated_contracts_unauthorized() { + let (server, _pool) = create_test_server().await; + + // Test without authorization header + let response = server.get("/generated_contracts").await; + assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED); + + // Test with invalid token + let response2 = server + .get("/generated_contracts") + .authorization_bearer("invalid-token") + .await; + assert_eq!(response2.status_code(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_list_generated_contracts_invalid_cursor() { + let (server, pool) = create_test_server().await; + let (_user_id, wallet) = create_test_user_with_profile(&pool).await; + + let token = create_jwt_token(&wallet); + + // Test with invalid base64 cursor + let response = server + .get("/generated_contracts?cursor=invalid-base64") + .authorization_bearer(&token) + .await; + + // Should handle gracefully - either return empty or first page + assert_eq!(response.status_code(), StatusCode::OK); + + let response_body: Value = response.json(); + assert!(response_body["items"].is_array()); + + cleanup_test_data(&pool).await; +} + +#[tokio::test] +async fn test_list_generated_contracts_limit_bounds() { + let (server, pool) = create_test_server().await; + let (user_id, wallet) = create_test_user_with_profile(&pool).await; + + // Create some contracts + for i in 0..10 { + let request_body = json!({ + "user_id": user_id, + "contract_type": "token", + "contract_name": format!("Token{}", i) + }); + + let response = server.post("/generate").json(&request_body).await; + assert_eq!(response.status_code(), StatusCode::CREATED); + } + + let token = create_jwt_token(&wallet); + + // Test minimum limit (should default to 1) + let response = server + .get("/generated_contracts?limit=0") + .authorization_bearer(&token) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + let response_body: Value = response.json(); + let items = response_body["items"].as_array().unwrap(); + assert!(!items.is_empty()); + + // Test maximum limit + let response2 = server + .get("/generated_contracts?limit=100") + .authorization_bearer(&token) + .await; + + assert_eq!(response2.status_code(), StatusCode::OK); + let response_body2: Value = response2.json(); + let items2 = response_body2["items"].as_array().unwrap(); + assert!(items2.len() <= 50); // Should be clamped to 50 + + cleanup_test_data(&pool).await; +} + +#[tokio::test] +async fn test_list_generated_contracts_user_isolation() { + let (server, pool) = create_test_server().await; + + // Create two users + let (user1_id, wallet1) = create_test_user_with_profile(&pool).await; + let (user2_id, wallet2) = create_test_user_with_profile(&pool).await; + + // Create contracts for user1 + for i in 0..3 { + let request_body = json!({ + "user_id": user1_id, + "contract_type": "token", + "contract_name": format!("User1Token{}", i) + }); + + let response = server.post("/generate").json(&request_body).await; + assert_eq!(response.status_code(), StatusCode::CREATED); + } + + // Create contracts for user2 + for i in 0..2 { + let request_body = json!({ + "user_id": user2_id, + "contract_type": "nft", + "contract_name": format!("User2NFT{}", i) + }); + + let response = server.post("/generate").json(&request_body).await; + assert_eq!(response.status_code(), StatusCode::CREATED); + } + + // Test user1 can only see their contracts + let token1 = create_jwt_token(&wallet1); + let response1 = server + .get("/generated_contracts") + .authorization_bearer(&token1) + .await; + + assert_eq!(response1.status_code(), StatusCode::OK); + let response_body1: Value = response1.json(); + let items1 = response_body1["items"].as_array().unwrap(); + assert_eq!(items1.len(), 3); + + for item in items1 { + assert_eq!(item["user_id"], user1_id); + assert_eq!(item["contract_type"], "token"); + assert!(item["contract_name"].as_str().unwrap().starts_with("User1")); + } + + // Test user2 can only see their contracts + let token2 = create_jwt_token(&wallet2); + let response2 = server + .get("/generated_contracts") + .authorization_bearer(&token2) + .await; + + assert_eq!(response2.status_code(), StatusCode::OK); + let response_body2: Value = response2.json(); + let items2 = response_body2["items"].as_array().unwrap(); + assert_eq!(items2.len(), 2); + + for item in items2 { + assert_eq!(item["user_id"], user2_id); + assert_eq!(item["contract_type"], "nft"); + assert!(item["contract_name"].as_str().unwrap().starts_with("User2")); + } + + cleanup_test_data(&pool).await; }