diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4c5b1e9..d6b3abb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -54,5 +54,9 @@ bigdecimal = { version = "0.4", features = ["serde"] } hex = "0.4" chrono = { version = "0.4", features = ["serde"] } +# Testing +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["postgres"] } + # Internal crates atlas-common = { path = "crates/atlas-common" } diff --git a/backend/crates/atlas-server/Cargo.toml b/backend/crates/atlas-server/Cargo.toml index d89113e..0bb864a 100644 --- a/backend/crates/atlas-server/Cargo.toml +++ b/backend/crates/atlas-server/Cargo.toml @@ -37,3 +37,11 @@ tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } tokio-postgres-rustls = "0.12" rustls = "0.23" webpki-roots = "0.26" + +[dev-dependencies] +testcontainers = { workspace = true } +testcontainers-modules = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true, features = ["util"] } +serde_json = { workspace = true } +sqlx = { workspace = true } diff --git a/backend/crates/atlas-server/src/head.rs b/backend/crates/atlas-server/src/head.rs index e00970a..13f273e 100644 --- a/backend/crates/atlas-server/src/head.rs +++ b/backend/crates/atlas-server/src/head.rs @@ -4,7 +4,7 @@ use std::collections::VecDeque; use tokio::sync::RwLock; use tracing::{info, warn}; -pub(crate) struct HeadTracker { +pub struct HeadTracker { replay_capacity: usize, state: RwLock, } @@ -15,17 +15,14 @@ struct HeadState { replay: VecDeque, } -pub(crate) struct ReplaySnapshot { +pub struct ReplaySnapshot { pub buffer_start: Option, pub buffer_end: Option, pub blocks_after_cursor: Vec, } impl HeadTracker { - pub(crate) async fn bootstrap( - pool: &PgPool, - replay_capacity: usize, - ) -> Result { + pub async fn bootstrap(pool: &PgPool, replay_capacity: usize) -> Result { let mut blocks = sqlx::query_as::<_, Block>(&format!( "SELECT {} FROM blocks ORDER BY number DESC LIMIT $1", BLOCK_COLUMNS @@ -49,19 +46,19 @@ impl HeadTracker { }) } - pub(crate) fn empty(replay_capacity: usize) -> Self { + pub fn empty(replay_capacity: usize) -> Self { Self { replay_capacity, state: RwLock::new(HeadState::default()), } } - pub(crate) async fn clear(&self) { + pub async fn clear(&self) { let mut state = self.state.write().await; *state = HeadState::default(); } - pub(crate) async fn publish_committed_batch(&self, blocks: Vec) { + pub async fn publish_committed_batch(&self, blocks: Vec) { if blocks.is_empty() { return; } @@ -89,11 +86,11 @@ impl HeadTracker { } } - pub(crate) async fn latest(&self) -> Option { + pub async fn latest(&self) -> Option { self.state.read().await.latest.clone() } - pub(crate) async fn replay_after(&self, after_block: Option) -> ReplaySnapshot { + pub async fn replay_after(&self, after_block: Option) -> ReplaySnapshot { let state = self.state.read().await; let blocks_after_cursor = match after_block { diff --git a/backend/crates/atlas-server/src/lib.rs b/backend/crates/atlas-server/src/lib.rs new file mode 100644 index 0000000..d478e47 --- /dev/null +++ b/backend/crates/atlas-server/src/lib.rs @@ -0,0 +1,3 @@ +pub mod api; +pub mod faucet; +pub mod head; diff --git a/backend/crates/atlas-server/tests/integration/addresses.rs b/backend/crates/atlas-server/tests/integration/addresses.rs new file mode 100644 index 0000000..eda4fa4 --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/addresses.rs @@ -0,0 +1,116 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +// Block range: 5000-5999 + +const ADDR: &str = "0x5000000000000000000000000000000000000001"; +const ADDR_TO: &str = "0x5000000000000000000000000000000000000002"; +const TX_HASH_A: &str = "0x5000000000000000000000000000000000000000000000000000000000000001"; +const TX_HASH_B: &str = "0x5000000000000000000000000000000000000000000000000000000000000002"; + +async fn seed_address_data(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(5000i64) + .bind(format!("0x{:064x}", 5000)) + .bind(format!("0x{:064x}", 4999)) + .bind(1_700_005_000i64) + .bind(42_000i64) + .bind(30_000_000i64) + .bind(2i32) + .execute(pool) + .await + .expect("seed block"); + + sqlx::query( + "INSERT INTO addresses (address, is_contract, first_seen_block, tx_count) + VALUES ($1, $2, $3, $4) + ON CONFLICT (address) DO NOTHING", + ) + .bind(ADDR) + .bind(true) + .bind(5000i64) + .bind(2i32) + .execute(pool) + .await + .expect("seed address"); + + for (idx, hash) in [TX_HASH_A, TX_HASH_B].iter().enumerate() { + sqlx::query( + "INSERT INTO transactions (hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (hash, block_number) DO NOTHING", + ) + .bind(hash) + .bind(5000i64) + .bind(idx as i32) + .bind(ADDR) + .bind(ADDR_TO) + .bind(0i64) + .bind(20_000_000_000i64) + .bind(21_000i64) + .bind(Vec::::new()) + .bind(true) + .bind(1_700_005_000i64) + .execute(pool) + .await + .expect("seed transaction"); + } +} + +#[test] +fn get_address_detail() { + common::run(async { + let pool = common::pool(); + seed_address_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/addresses/{}", ADDR)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body["address"].as_str().unwrap(), ADDR); + assert_eq!(body["address_type"].as_str().unwrap(), "contract"); + assert_eq!(body["tx_count"].as_i64().unwrap(), 2); + }); +} + +#[test] +fn get_address_transactions() { + common::run(async { + let pool = common::pool(); + seed_address_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/addresses/{}/transactions", ADDR)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert_eq!(data.len(), 2); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/blocks.rs b/backend/crates/atlas-server/tests/integration/blocks.rs new file mode 100644 index 0000000..bb56f46 --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/blocks.rs @@ -0,0 +1,101 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +// Block range: 1000-1999 + +async fn seed_blocks(pool: &sqlx::PgPool) { + for i in 1000..1005 { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(i as i64) + .bind(format!("0x{:064x}", i)) + .bind(format!("0x{:064x}", i - 1)) + .bind(1_700_000_000i64 + i as i64) + .bind(21_000i64) + .bind(30_000_000i64) + .bind(0i32) + .execute(pool) + .await + .expect("seed block"); + } +} + +#[test] +fn list_blocks_paginated() { + common::run(async { + let pool = common::pool(); + seed_blocks(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/blocks?page=1&limit=2") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body["data"].as_array().unwrap().len(), 2); + assert!(body["total"].as_i64().unwrap() >= 5); + + // Blocks should be in DESC order + let first = body["data"][0]["number"].as_i64().unwrap(); + let second = body["data"][1]["number"].as_i64().unwrap(); + assert!(first > second); + }); +} + +#[test] +fn get_block_by_number() { + common::run(async { + let pool = common::pool(); + seed_blocks(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/blocks/1002") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body["number"].as_i64().unwrap(), 1002); + assert_eq!(body["hash"].as_str().unwrap(), &format!("0x{:064x}", 1002)); + assert_eq!(body["gas_used"].as_i64().unwrap(), 21_000); + }); +} + +#[test] +fn get_block_not_found() { + common::run(async { + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/blocks/999999") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/common.rs b/backend/crates/atlas-server/tests/integration/common.rs new file mode 100644 index 0000000..de469f3 --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/common.rs @@ -0,0 +1,94 @@ +use axum::Router; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::sync::{Arc, LazyLock}; +use testcontainers::runners::AsyncRunner; +use testcontainers::ContainerAsync; +use testcontainers_modules::postgres::Postgres; +use tokio::sync::broadcast; + +use atlas_server::api::{build_router, AppState}; +use atlas_server::head::HeadTracker; + +struct TestEnv { + runtime: tokio::runtime::Runtime, + pool: PgPool, + _container: ContainerAsync, +} + +// Single LazyLock: runtime + container + pool, all initialized together. +static ENV: LazyLock = LazyLock::new(|| { + let runtime = tokio::runtime::Runtime::new().expect("create test runtime"); + + let (pool, container) = runtime.block_on(async { + let container = Postgres::default() + .start() + .await + .expect("Failed to start Postgres container"); + + let host = container.get_host().await.expect("get host"); + let port = container.get_host_port_ipv4(5432).await.expect("get port"); + + let database_url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&database_url) + .await + .expect("Failed to create pool"); + + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + (pool, container) + }); + + TestEnv { + runtime, + pool, + _container: container, + } +}); + +pub fn pool() -> &'static PgPool { + &ENV.pool +} + +pub fn test_router() -> Router { + let pool = pool().clone(); + let head_tracker = Arc::new(HeadTracker::empty(10)); + let (tx, _) = broadcast::channel(1); + + let state = Arc::new(AppState { + pool, + block_events_tx: tx, + head_tracker, + rpc_url: String::new(), + faucet: None, + chain_id: 42, + chain_name: "Test Chain".to_string(), + chain_logo_url: None, + accent_color: None, + background_color_dark: None, + background_color_light: None, + success_color: None, + error_color: None, + }); + + build_router(state, None) +} + +/// Run an async test block on the shared runtime. +pub fn run>(f: F) { + ENV.runtime.block_on(f); +} + +/// Helper to parse a JSON response body. +pub async fn json_body(response: axum::http::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + serde_json::from_slice(&bytes).expect("parse JSON") +} diff --git a/backend/crates/atlas-server/tests/integration/main.rs b/backend/crates/atlas-server/tests/integration/main.rs new file mode 100644 index 0000000..cb27ff0 --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/main.rs @@ -0,0 +1,9 @@ +mod common; + +mod addresses; +mod blocks; +mod nfts; +mod search; +mod status; +mod tokens; +mod transactions; diff --git a/backend/crates/atlas-server/tests/integration/nfts.rs b/backend/crates/atlas-server/tests/integration/nfts.rs new file mode 100644 index 0000000..de57e6a --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/nfts.rs @@ -0,0 +1,164 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +// Block range: 7000-7999 + +const NFT_A: &str = "0x7000000000000000000000000000000000000001"; +const NFT_B: &str = "0x7000000000000000000000000000000000000002"; +const OWNER: &str = "0x7000000000000000000000000000000000000010"; +const TX_HASH_NFT: &str = "0x7000000000000000000000000000000000000000000000000000000000000001"; + +async fn seed_nft_data(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(7000i64) + .bind(format!("0x{:064x}", 7000)) + .bind(format!("0x{:064x}", 6999)) + .bind(1_700_007_000i64) + .bind(100_000i64) + .bind(30_000_000i64) + .bind(1i32) + .execute(pool) + .await + .expect("seed block"); + + for (addr, name, symbol) in [(NFT_A, "Apes", "APE"), (NFT_B, "Punks", "PUNK")] { + sqlx::query( + "INSERT INTO nft_contracts (address, name, symbol, total_supply, first_seen_block) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (address) DO NOTHING", + ) + .bind(addr) + .bind(name) + .bind(symbol) + .bind(100i64) + .bind(7000i64) + .execute(pool) + .await + .expect("seed nft contract"); + } + + for token_id in 1..=3i64 { + sqlx::query( + "INSERT INTO nft_tokens (contract_address, token_id, owner, metadata_fetched, last_transfer_block) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (contract_address, token_id) DO NOTHING", + ) + .bind(NFT_A) + .bind(bigdecimal::BigDecimal::from(token_id)) + .bind(OWNER) + .bind(false) + .bind(7000i64) + .execute(pool) + .await + .expect("seed nft token"); + } + + for (log_idx, token_id) in [0i32, 1, 2].iter().zip(1..=3i64) { + sqlx::query( + "INSERT INTO nft_transfers (tx_hash, log_index, contract_address, token_id, from_address, to_address, block_number, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (tx_hash, log_index, block_number) DO NOTHING", + ) + .bind(TX_HASH_NFT) + .bind(log_idx) + .bind(NFT_A) + .bind(bigdecimal::BigDecimal::from(token_id)) + .bind("0x0000000000000000000000000000000000000000") + .bind(OWNER) + .bind(7000i64) + .bind(1_700_007_000i64) + .execute(pool) + .await + .expect("seed nft transfer"); + } +} + +#[test] +fn list_nft_collections() { + common::run(async { + let pool = common::pool(); + seed_nft_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/nfts/collections?page=1&limit=100") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert!(body["data"].as_array().unwrap().len() >= 2); + }); +} + +#[test] +fn list_collection_tokens() { + common::run(async { + let pool = common::pool(); + seed_nft_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/nfts/collections/{}/tokens", NFT_A)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert_eq!(data.len(), 3); + + // Ordered by token_id ASC (token_id is BigDecimal, may serialize as string or number) + let parse_token_id = |v: &serde_json::Value| -> i64 { + v.as_i64() + .unwrap_or_else(|| v.as_str().unwrap().parse().unwrap()) + }; + let id0 = parse_token_id(&data[0]["token_id"]); + let id1 = parse_token_id(&data[1]["token_id"]); + assert!(id0 < id1); + }); +} + +#[test] +fn get_collection_transfers() { + common::run(async { + let pool = common::pool(); + seed_nft_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/nfts/collections/{}/transfers", NFT_A)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert_eq!(data.len(), 3); + assert_eq!(body["total"].as_i64().unwrap(), 3); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/search.rs b/backend/crates/atlas-server/tests/integration/search.rs new file mode 100644 index 0000000..3745be0 --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/search.rs @@ -0,0 +1,164 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +// Block range: 3000-3999 + +const SEARCH_BLOCK: i64 = 3000; +const SEARCH_TX_HASH: &str = "0x3000000000000000000000000000000000000000000000000000000000000001"; +const SEARCH_ADDR: &str = "0x3000000000000000000000000000000000000001"; + +async fn seed_search_data(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(SEARCH_BLOCK) + .bind(format!("0x{:064x}", SEARCH_BLOCK)) + .bind(format!("0x{:064x}", SEARCH_BLOCK - 1)) + .bind(1_700_003_000i64) + .bind(21_000i64) + .bind(30_000_000i64) + .bind(1i32) + .execute(pool) + .await + .expect("seed block"); + + sqlx::query( + "INSERT INTO transactions (hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (hash, block_number) DO NOTHING", + ) + .bind(SEARCH_TX_HASH) + .bind(SEARCH_BLOCK) + .bind(0i32) + .bind(SEARCH_ADDR) + .bind("0x3000000000000000000000000000000000000002") + .bind(0i64) + .bind(20_000_000_000i64) + .bind(21_000i64) + .bind(Vec::::new()) + .bind(true) + .bind(1_700_003_000i64) + .execute(pool) + .await + .expect("seed transaction"); + + sqlx::query( + "INSERT INTO tx_hash_lookup (hash, block_number) + VALUES ($1, $2) + ON CONFLICT (hash) DO NOTHING", + ) + .bind(SEARCH_TX_HASH) + .bind(SEARCH_BLOCK) + .execute(pool) + .await + .expect("seed tx_hash_lookup"); + + sqlx::query( + "INSERT INTO addresses (address, is_contract, first_seen_block, tx_count) + VALUES ($1, $2, $3, $4) + ON CONFLICT (address) DO NOTHING", + ) + .bind(SEARCH_ADDR) + .bind(false) + .bind(SEARCH_BLOCK) + .bind(1i32) + .execute(pool) + .await + .expect("seed address"); +} + +#[test] +fn search_by_block_hash() { + common::run(async { + let pool = common::pool(); + seed_search_data(pool).await; + + // Search by block hash (66 chars = 0x + 64 hex) + let block_hash = format!("0x{:064x}", SEARCH_BLOCK); + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/search?q={}", block_hash)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let results = body["results"].as_array().unwrap(); + let block_result = results + .iter() + .find(|r| r["type"].as_str().unwrap() == "block"); + assert!(block_result.is_some()); + assert_eq!( + block_result.unwrap()["number"].as_i64().unwrap(), + SEARCH_BLOCK + ); + }); +} + +#[test] +fn search_by_tx_hash() { + common::run(async { + let pool = common::pool(); + seed_search_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/search?q={}", SEARCH_TX_HASH)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let results = body["results"].as_array().unwrap(); + assert!(!results.is_empty()); + + let tx_result = results + .iter() + .find(|r| r["type"].as_str().unwrap() == "transaction"); + assert!(tx_result.is_some()); + assert_eq!(tx_result.unwrap()["hash"].as_str().unwrap(), SEARCH_TX_HASH); + }); +} + +#[test] +fn search_by_address() { + common::run(async { + let pool = common::pool(); + seed_search_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/search?q={}", SEARCH_ADDR)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let results = body["results"].as_array().unwrap(); + assert!(!results.is_empty()); + assert_eq!(results[0]["type"].as_str().unwrap(), "address"); + assert_eq!(results[0]["address"].as_str().unwrap(), SEARCH_ADDR); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/status.rs b/backend/crates/atlas-server/tests/integration/status.rs new file mode 100644 index 0000000..4c89703 --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/status.rs @@ -0,0 +1,47 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +#[test] +fn health_returns_ok() { + common::run(async { + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + }); +} + +#[test] +fn status_returns_chain_info() { + common::run(async { + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body["chain_id"].as_str().unwrap(), "42"); + assert_eq!(body["chain_name"].as_str().unwrap(), "Test Chain"); + assert!(body["block_height"].is_i64()); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/tokens.rs b/backend/crates/atlas-server/tests/integration/tokens.rs new file mode 100644 index 0000000..91799fd --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/tokens.rs @@ -0,0 +1,219 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +// Block range: 6000-6999 + +const TOKEN_A: &str = "0x6000000000000000000000000000000000000001"; +const TOKEN_B: &str = "0x6000000000000000000000000000000000000002"; +const HOLDER_1: &str = "0x6000000000000000000000000000000000000010"; +const HOLDER_2: &str = "0x6000000000000000000000000000000000000011"; +const TX_HASH: &str = "0x6000000000000000000000000000000000000000000000000000000000000001"; + +async fn seed_token_data(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(6000i64) + .bind(format!("0x{:064x}", 6000)) + .bind(format!("0x{:064x}", 5999)) + .bind(1_700_006_000i64) + .bind(100_000i64) + .bind(30_000_000i64) + .bind(1i32) + .execute(pool) + .await + .expect("seed block"); + + sqlx::query( + "INSERT INTO erc20_contracts (address, name, symbol, decimals, total_supply, first_seen_block) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (address) DO NOTHING", + ) + .bind(TOKEN_A) + .bind("Test Token A") + .bind("TTA") + .bind(18i16) + .bind(bigdecimal::BigDecimal::from(1_000_000i64)) + .bind(6000i64) + .execute(pool) + .await + .expect("seed erc20 contract A"); + + sqlx::query( + "INSERT INTO erc20_contracts (address, name, symbol, decimals, first_seen_block) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (address) DO NOTHING", + ) + .bind(TOKEN_B) + .bind("Test Token B") + .bind("TTB") + .bind(6i16) + .bind(6001i64) + .execute(pool) + .await + .expect("seed erc20 contract B"); + + for (holder, balance) in [(HOLDER_1, 700_000i64), (HOLDER_2, 300_000i64)] { + sqlx::query( + "INSERT INTO erc20_balances (address, contract_address, balance, last_updated_block) + VALUES ($1, $2, $3, $4) + ON CONFLICT (address, contract_address) DO NOTHING", + ) + .bind(holder) + .bind(TOKEN_A) + .bind(bigdecimal::BigDecimal::from(balance)) + .bind(6000i64) + .execute(pool) + .await + .expect("seed balance"); + } + + sqlx::query( + "INSERT INTO transactions (hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (hash, block_number) DO NOTHING", + ) + .bind(TX_HASH) + .bind(6000i64) + .bind(0i32) + .bind(HOLDER_1) + .bind(TOKEN_A) + .bind(0i64) + .bind(20_000_000_000i64) + .bind(60_000i64) + .bind(Vec::::new()) + .bind(true) + .bind(1_700_006_000i64) + .execute(pool) + .await + .expect("seed transaction"); + + sqlx::query( + "INSERT INTO erc20_transfers (tx_hash, log_index, contract_address, from_address, to_address, value, block_number, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (tx_hash, log_index, block_number) DO NOTHING", + ) + .bind(TX_HASH) + .bind(0i32) + .bind(TOKEN_A) + .bind(HOLDER_1) + .bind(HOLDER_2) + .bind(bigdecimal::BigDecimal::from(50_000i64)) + .bind(6000i64) + .bind(1_700_006_000i64) + .execute(pool) + .await + .expect("seed erc20 transfer"); +} + +#[test] +fn list_tokens() { + common::run(async { + let pool = common::pool(); + seed_token_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/tokens?page=1&limit=100") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert!(body["data"].as_array().unwrap().len() >= 2); + }); +} + +#[test] +fn get_token_detail() { + common::run(async { + let pool = common::pool(); + seed_token_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/tokens/{}", TOKEN_A)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body["address"].as_str().unwrap(), TOKEN_A); + assert_eq!(body["name"].as_str().unwrap(), "Test Token A"); + assert_eq!(body["symbol"].as_str().unwrap(), "TTA"); + assert_eq!(body["holder_count"].as_i64().unwrap(), 2); + assert_eq!(body["transfer_count"].as_i64().unwrap(), 1); + }); +} + +#[test] +fn get_token_holders() { + common::run(async { + let pool = common::pool(); + seed_token_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/tokens/{}/holders", TOKEN_A)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert_eq!(data.len(), 2); + + // First holder should have largest balance (sorted DESC) + let first_pct = data[0]["percentage"].as_f64().unwrap(); + assert!(first_pct > 50.0); // 700k/1M = 70% + }); +} + +#[test] +fn get_tx_erc20_transfers() { + common::run(async { + let pool = common::pool(); + seed_token_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/transactions/{}/erc20-transfers", TX_HASH)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert_eq!(data.len(), 1); + assert_eq!(data[0]["contract_address"].as_str().unwrap(), TOKEN_A); + assert_eq!(data[0]["from_address"].as_str().unwrap(), HOLDER_1); + assert_eq!(data[0]["to_address"].as_str().unwrap(), HOLDER_2); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/transactions.rs b/backend/crates/atlas-server/tests/integration/transactions.rs new file mode 100644 index 0000000..a57b19c --- /dev/null +++ b/backend/crates/atlas-server/tests/integration/transactions.rs @@ -0,0 +1,153 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::ServiceExt; + +use crate::common; + +// Block range: 2000-2999 + +const TX_HASH_1: &str = "0x2000000000000000000000000000000000000000000000000000000000000001"; +const TX_HASH_2: &str = "0x2000000000000000000000000000000000000000000000000000000000000002"; +const TX_HASH_3: &str = "0x2000000000000000000000000000000000000000000000000000000000000003"; +const FROM_ADDR: &str = "0x2000000000000000000000000000000000000001"; +const TO_ADDR: &str = "0x2000000000000000000000000000000000000002"; + +async fn seed_transactions(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(2000i64) + .bind(format!("0x{:064x}", 2000)) + .bind(format!("0x{:064x}", 1999)) + .bind(1_700_002_000i64) + .bind(63_000i64) + .bind(30_000_000i64) + .bind(3i32) + .execute(pool) + .await + .expect("seed block"); + + let hashes = [TX_HASH_1, TX_HASH_2, TX_HASH_3]; + for (idx, hash) in hashes.iter().enumerate() { + sqlx::query( + "INSERT INTO transactions (hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (hash, block_number) DO NOTHING", + ) + .bind(hash) + .bind(2000i64) + .bind(idx as i32) + .bind(FROM_ADDR) + .bind(TO_ADDR) + .bind(1_000_000_000_000_000_000i64) + .bind(20_000_000_000i64) + .bind(21_000i64) + .bind(Vec::::new()) + .bind(true) + .bind(1_700_002_000i64) + .execute(pool) + .await + .expect("seed transaction"); + } +} + +#[test] +fn list_transactions() { + common::run(async { + let pool = common::pool(); + seed_transactions(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/transactions?page=1&limit=100") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert!(data.len() >= 3); + }); +} + +#[test] +fn get_transaction_by_hash() { + common::run(async { + let pool = common::pool(); + seed_transactions(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/transactions/{}", TX_HASH_1)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body["hash"].as_str().unwrap(), TX_HASH_1); + assert_eq!(body["block_number"].as_i64().unwrap(), 2000); + assert!(body["status"].as_bool().unwrap()); + }); +} + +#[test] +fn get_transaction_not_found() { + common::run(async { + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/transactions/0x0000000000000000000000000000000000000000000000000000000000000000") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + }); +} + +#[test] +fn get_block_transactions() { + common::run(async { + let pool = common::pool(); + seed_transactions(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri("/api/blocks/2000/transactions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + let data = body["data"].as_array().unwrap(); + assert_eq!(data.len(), 3); + + // Should be ordered by block_index ASC + let idx0 = data[0]["block_index"].as_i64().unwrap(); + let idx1 = data[1]["block_index"].as_i64().unwrap(); + let idx2 = data[2]["block_index"].as_i64().unwrap(); + assert!(idx0 < idx1 && idx1 < idx2); + }); +}