diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebdb3b8..edcddd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,8 @@ concurrency: cancel-in-progress: true jobs: - backend: - name: Backend (Rust) + backend-fmt: + name: Backend Format runs-on: ubuntu-latest defaults: run: @@ -24,19 +24,52 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy + components: rustfmt + + - name: Format + run: cargo fmt --all --check + + backend-clippy: + name: Backend Clippy + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: workspaces: backend - - name: Format - run: cargo fmt --all --check - - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings + backend-test: + name: Backend Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + - name: Test run: cargo test --workspace --all-targets diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e671c15 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,71 @@ +name: Docker Build + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-indexer: + name: Indexer Docker (linux/amd64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build indexer image + uses: docker/build-push-action@v6 + with: + context: backend + target: indexer + platforms: linux/amd64 + cache-from: type=gha,scope=backend + cache-to: type=gha,scope=backend,mode=max + outputs: type=cacheonly + + build-api: + name: API Docker (linux/amd64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build api image + uses: docker/build-push-action@v6 + with: + context: backend + target: api + platforms: linux/amd64 + cache-from: type=gha,scope=backend + cache-to: type=gha,scope=backend,mode=max + outputs: type=cacheonly + + build-frontend: + name: Frontend Docker (linux/amd64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build frontend image + uses: docker/build-push-action@v6 + with: + context: frontend + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=cacheonly diff --git a/CLAUDE.md b/CLAUDE.md index dc7a8d6..d35bb60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ pub struct AppState { - **Migrations**: use `run_migrations(&database_url)` (not `&pool`) to get a timeout-free connection - **Frontend**: uses Bun (not npm/yarn). Lockfile is `bun.lock` (text, Bun ≥ 1.2). Build with `bunx vite build` (skips tsc type check). - **Docker**: frontend image uses `nginxinc/nginx-unprivileged:alpine` (non-root, port 8080). API/indexer use `alpine` with `ca-certificates`. +- **Tests**: add unit tests for new logic in a `#[cfg(test)] mod tests` block in the same file. Run with `cargo test --workspace`. - **Commits**: authored by the user only — no Claude co-author lines. ## Environment Variables diff --git a/backend/crates/atlas-indexer/src/batch.rs b/backend/crates/atlas-indexer/src/batch.rs index a3e8ffd..b4fb8f4 100644 --- a/backend/crates/atlas-indexer/src/batch.rs +++ b/backend/crates/atlas-indexer/src/batch.rs @@ -155,3 +155,79 @@ impl BlockBatch { entry.last_block = entry.last_block.max(block); } } + +#[cfg(test)] +mod tests { + use super::*; + use bigdecimal::BigDecimal; + + // --- touch_addr tests --- + + #[test] + fn touch_addr_keeps_minimum_first_seen_block() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xabc".to_string(), 200, false, 0); + batch.touch_addr("0xabc".to_string(), 100, false, 0); + + assert_eq!(batch.addr_map["0xabc"].first_seen_block, 100); + } + + #[test] + fn touch_addr_is_contract_latches_true() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xabc".to_string(), 100, false, 0); + batch.touch_addr("0xabc".to_string(), 101, true, 0); + + assert!(batch.addr_map["0xabc"].is_contract); + } + + // --- apply_balance_delta tests --- + + #[test] + fn apply_balance_delta_accumulates_positive() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(100), + 50, + ); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(50), + 60, + ); + + let entry = batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken".to_string())) + .unwrap(); + assert_eq!(entry.delta, BigDecimal::from(150)); + assert_eq!(entry.last_block, 60); + } + + #[test] + fn apply_balance_delta_tracks_max_block() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(1), + 100, + ); + // Earlier block — last_block should stay at 100 + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(1), + 50, + ); + + let entry = batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken".to_string())) + .unwrap(); + assert_eq!(entry.last_block, 100); + } +} diff --git a/backend/crates/atlas-indexer/src/indexer.rs b/backend/crates/atlas-indexer/src/indexer.rs index f2c26c3..8ab565d 100644 --- a/backend/crates/atlas-indexer/src/indexer.rs +++ b/backend/crates/atlas-indexer/src/indexer.rs @@ -906,3 +906,410 @@ impl Indexer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_fetched_block(number: u64) -> FetchedBlock { + FetchedBlock { + number, + block: alloy::rpc::types::Block::default(), + receipts: vec![], + } + } + + fn make_receipt(logs_json: serde_json::Value) -> alloy::rpc::types::TransactionReceipt { + let receipt_json = serde_json::json!({ + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "blockNumber": "0x1", + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "cumulativeGasUsed": "0x5208", + "gasUsed": "0x5208", + "contractAddress": null, + "logs": logs_json, + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "effectiveGasPrice": "0x1", + "status": "0x1" + }); + serde_json::from_value(receipt_json).expect("valid receipt JSON") + } + + #[test] + fn collect_erc20_transfer_populates_transfer_and_balance_arrays() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // ERC-20 Transfer: 3 topics + 32 bytes data (value = 1000) + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + assert_eq!(batch.et_contracts.len(), 1); + assert_eq!(batch.et_froms.len(), 1); + assert_eq!(batch.et_tos.len(), 1); + assert_eq!(batch.et_values, vec!["1000".to_string()]); + + // New ERC-20 contract registered + assert_eq!(batch.ec_addresses.len(), 1); + assert_eq!(batch.new_erc20.len(), 1); + + // Two balance deltas: sender (negative) and receiver (positive) + assert_eq!(batch.balance_map.len(), 2); + + let contract = batch.ec_addresses[0].clone(); + let from = "0x1111111111111111111111111111111111111111"; + let to = "0x2222222222222222222222222222222222222222"; + + let sender_delta = &batch.balance_map[&(from.to_string(), contract.clone())]; + assert!(sender_delta.delta < 0); + + let receiver_delta = &batch.balance_map[&(to.to_string(), contract)]; + assert!(receiver_delta.delta > 0); + } + + #[test] + fn collect_erc20_mint_skips_zero_address_balance_delta() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // Mint: from = ZERO_ADDRESS → no balance delta for sender + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + // Only the receiver gets a balance delta; zero address is excluded + assert_eq!(batch.balance_map.len(), 1); + let contract = batch.ec_addresses[0].clone(); + let to = "0x2222222222222222222222222222222222222222"; + assert!(batch.balance_map.contains_key(&(to.to_string(), contract))); + } + + #[test] + fn collect_erc20_known_contract_not_added_to_ec_addresses() { + let mut batch = BlockBatch::new(); + let mut known_erc20 = HashSet::new(); + known_erc20.insert("0x3333333333333333333333333333333333333333".to_string()); + let known_nft = HashSet::new(); + + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + // Transfer is still recorded + assert_eq!(batch.et_contracts.len(), 1); + // But contract is NOT added again (already in known_erc20) + assert_eq!(batch.ec_addresses.len(), 0); + assert_eq!(batch.new_erc20.len(), 0); + } + + #[test] + fn collect_erc721_transfer_populates_nft_arrays() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // ERC-721 Transfer: 4 topics, token ID = 42, empty data + let logs = serde_json::json!([{ + "address": "0x4444444444444444444444444444444444444444", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x000000000000000000000000000000000000000000000000000000000000002a" + ], + "data": "0x", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + assert_eq!(batch.nt_contracts.len(), 1); + assert_eq!(batch.nt_token_ids, vec!["42".to_string()]); + assert_eq!(batch.nt_froms.len(), 1); + assert_eq!(batch.nt_tos.len(), 1); + + // New NFT contract registered + assert_eq!(batch.nft_contract_addrs.len(), 1); + assert_eq!(batch.new_nft.len(), 1); + + // NFT token ownership tracked in nft_token_map + assert_eq!(batch.nft_token_map.len(), 1); + + // No ERC-20 data and no balance deltas + assert!(batch.et_contracts.is_empty()); + assert!(batch.balance_map.is_empty()); + } + + #[test] + fn collect_ambiguous_transfer_skipped_when_data_too_short() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // 3 topics but only 2 bytes of data → neither ERC-20 nor ERC-721 + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x1234", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + assert!(batch.et_contracts.is_empty()); + assert!(batch.nt_contracts.is_empty()); + assert!(batch.ec_addresses.is_empty()); + assert!(batch.nft_contract_addrs.is_empty()); + assert!(batch.balance_map.is_empty()); + } + + #[test] + fn collect_erc20_two_transfers_in_same_block_aggregate_balance_deltas() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // Two transfers from 0x1111 to 0x2222, each of value 1000 + let logs = serde_json::json!([ + { + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }, + { + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x1", + "removed": false + } + ]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + let contract = batch.ec_addresses[0].clone(); + let from = "0x1111111111111111111111111111111111111111"; + let to = "0x2222222222222222222222222222222222222222"; + + assert_eq!(batch.balance_map.len(), 2); + assert_eq!( + batch.balance_map[&(from.to_string(), contract.clone())].delta, + BigDecimal::from(-2000) + ); + assert_eq!( + batch.balance_map[&(to.to_string(), contract)].delta, + BigDecimal::from(2000) + ); + } + + #[test] + fn collect_log_emitter_registered_as_contract_in_addr_map() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // A non-Transfer log — any event emission marks the address as a contract + let logs = serde_json::json!([{ + "address": "0x5555555555555555555555555555555555555555", + "topics": ["0x1111111111111111111111111111111111111111111111111111111111111111"], + "data": "0x", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + let emitter = "0x5555555555555555555555555555555555555555"; + assert!(batch.addr_map[emitter].is_contract); + } + + #[test] + fn collect_erc721_known_contract_not_added_to_nft_contract_addrs() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let mut known_nft = HashSet::new(); + known_nft.insert("0x4444444444444444444444444444444444444444".to_string()); + + let logs = serde_json::json!([{ + "address": "0x4444444444444444444444444444444444444444", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x000000000000000000000000000000000000000000000000000000000000002a" + ], + "data": "0x", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + // Transfer still recorded + assert_eq!(batch.nt_contracts.len(), 1); + // Contract NOT re-registered + assert!(batch.nft_contract_addrs.is_empty()); + assert!(batch.new_nft.is_empty()); + } + + #[test] + fn collect_erc721_second_transfer_of_same_token_overwrites_owner() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // Token #42: first transferred to 0x2222, then to 0x3333 in the same batch + let logs = serde_json::json!([ + { + "address": "0x4444444444444444444444444444444444444444", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x000000000000000000000000000000000000000000000000000000000000002a" + ], + "data": "0x", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }, + { + "address": "0x4444444444444444444444444444444444444444", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x0000000000000000000000003333333333333333333333333333333333333333", + "0x000000000000000000000000000000000000000000000000000000000000002a" + ], + "data": "0x", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x1", + "removed": false + } + ]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + // Both transfers recorded + assert_eq!(batch.nt_contracts.len(), 2); + // Only one nft_token_map entry for this token + assert_eq!(batch.nft_token_map.len(), 1); + // Last transfer wins — final owner is 0x3333 + let contract = "0x4444444444444444444444444444444444444444".to_string(); + let state = &batch.nft_token_map[&(contract, "42".to_string())]; + assert_eq!(state.owner, "0x3333333333333333333333333333333333333333"); + } +} diff --git a/backend/crates/atlas-indexer/src/metadata.rs b/backend/crates/atlas-indexer/src/metadata.rs index 3af8cb1..9b1d605 100644 --- a/backend/crates/atlas-indexer/src/metadata.rs +++ b/backend/crates/atlas-indexer/src/metadata.rs @@ -536,3 +536,36 @@ fn resolve_uri(uri: &str, ipfs_gateway: &str) -> String { uri.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + const GATEWAY: &str = "https://ipfs.io/ipfs/"; + + #[test] + fn resolve_ipfs_uri_prefixes_gateway() { + assert_eq!( + resolve_uri("ipfs://QmXxx123", GATEWAY), + "https://ipfs.io/ipfs/QmXxx123" + ); + } + + #[test] + fn resolve_arweave_uri() { + assert_eq!( + resolve_uri("ar://txid123", GATEWAY), + "https://arweave.net/txid123" + ); + } + + #[test] + fn non_rewritten_schemes_are_unchanged() { + for url in [ + "data:image/png;base64,abc123==", + "https://example.com/metadata/1.json", + ] { + assert_eq!(resolve_uri(url, GATEWAY), url); + } + } +}