Skip to content

Commit 5c84368

Browse files
gnd: add mock Arweave resolver for file/arweave data source testing
Introduces `MockArweaveResolver` and the `arweaveFiles` schema field so `gnd test` can serve pre-loaded Arweave content without hitting the network. Unresolved tx IDs are collected and reported as a clear test failure, mirroring the existing IPFS mock behaviour.
1 parent 5eba8c3 commit 5c84368

4 files changed

Lines changed: 180 additions & 26 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! Mock Arweave resolver for `gnd test`.
2+
//!
3+
//! Replaces the real `ArweaveClient` with a map of pre-loaded txId → bytes.
4+
//! Any txId not found in the map is sent to the `unresolved_tx` channel and
5+
//! `ServerUnavailable` is returned, which causes the `PollingMonitor` to retry
6+
//! with backoff. After sync, the runner drains the channel and reports missing
7+
//! tx IDs as a clear test failure.
8+
9+
use std::collections::HashMap;
10+
11+
use async_trait::async_trait;
12+
use graph::bytes::Bytes;
13+
use graph::components::link_resolver::{ArweaveClientError, ArweaveResolver, FileSizeLimit};
14+
use graph::data_source::offchain::Base64;
15+
use tokio::sync::mpsc::UnboundedSender;
16+
17+
#[derive(Debug)]
18+
pub struct MockArweaveResolver {
19+
pub files: HashMap<String, Bytes>,
20+
pub unresolved_tx: UnboundedSender<String>,
21+
}
22+
23+
#[async_trait]
24+
impl ArweaveResolver for MockArweaveResolver {
25+
async fn get(&self, file: &Base64) -> Result<Vec<u8>, ArweaveClientError> {
26+
self.get_with_limit(file, &FileSizeLimit::Unlimited).await
27+
}
28+
29+
async fn get_with_limit(
30+
&self,
31+
file: &Base64,
32+
_limit: &FileSizeLimit,
33+
) -> Result<Vec<u8>, ArweaveClientError> {
34+
match self.files.get(file.as_str()) {
35+
Some(bytes) => Ok(bytes.to_vec()),
36+
None => {
37+
let _ = self.unresolved_tx.send(file.as_str().to_owned());
38+
Err(ArweaveClientError::ServerUnavailable(format!(
39+
"txId '{}' not found in mock 'arweaveFiles'",
40+
file.as_str()
41+
)))
42+
}
43+
}
44+
}
45+
}

gnd/src/commands/test/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ mod assertion;
3131
mod block_stream;
3232
mod eth_calls;
3333
mod matchstick;
34+
mod mock_arweave;
3435
mod mock_chain;
3536
mod mock_ipfs;
3637
mod noop;

gnd/src/commands/test/runner.rs

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
1313
use super::assertion::run_assertions;
1414
use super::block_stream::StaticStreamBuilder;
15+
use super::mock_arweave::MockArweaveResolver;
1516
use super::mock_chain;
1617
use super::mock_ipfs::MockIpfsClient;
1718
use super::noop::{NoopAdapterSelector, StaticBlockRefetcher};
@@ -24,7 +25,7 @@ use graph::amp::FlightClient;
2425
use graph::blockchain::block_stream::BlockWithTriggers;
2526
use graph::blockchain::{BlockPtr, BlockchainMap, ChainIdentifier};
2627
use graph::cheap_clone::CheapClone;
27-
use graph::components::link_resolver::{ArweaveClient, FileLinkResolver};
28+
use graph::components::link_resolver::FileLinkResolver;
2829
use graph::components::metrics::MetricsRegistry;
2930
use graph::components::network_provider::{
3031
AmpChainNames, ChainName, ProviderCheckStrategy, ProviderManager,
@@ -81,6 +82,14 @@ fn make_test_logger(verbose: u8) -> Logger {
8182
}
8283
}
8384

85+
/// Mock file data passed to `setup_context`.
86+
struct MockData {
87+
ipfs_files: HashMap<ContentPath, graph::bytes::Bytes>,
88+
ipfs_unresolved_tx: tokio::sync::mpsc::UnboundedSender<ContentPath>,
89+
arweave_files: HashMap<String, graph::bytes::Bytes>,
90+
arweave_unresolved_tx: tokio::sync::mpsc::UnboundedSender<String>,
91+
}
92+
8493
struct TestStores {
8594
network_name: ChainName,
8695
/// Listens for chain head updates — needed by the Chain constructor.
@@ -307,6 +316,20 @@ pub async fn run_single_test(
307316

308317
let (unresolved_tx, mut unresolved_rx) = tokio::sync::mpsc::unbounded_channel::<ContentPath>();
309318

319+
let mut mock_arweave_files: HashMap<String, graph::bytes::Bytes> = HashMap::new();
320+
for entry in &test_file.arweave_files {
321+
let content = entry.resolve(&test_file_dir).with_context(|| {
322+
format!(
323+
"Failed to resolve mock Arweave file for txId '{}'",
324+
entry.tx_id
325+
)
326+
})?;
327+
mock_arweave_files.insert(entry.tx_id.clone(), content);
328+
}
329+
330+
let (arweave_unresolved_tx, mut arweave_unresolved_rx) =
331+
tokio::sync::mpsc::unbounded_channel::<String>();
332+
310333
// Create the database for this test. For pgtemp, the `db` value must
311334
// stay alive for the duration of the test — dropping it destroys the database.
312335
let db = create_test_database(opt, &manifest_info.build_dir)?;
@@ -317,15 +340,14 @@ pub async fn run_single_test(
317340

318341
let chain = setup_chain(&logger, blocks.clone(), &stores).await?;
319342

320-
let ctx = setup_context(
321-
&logger,
322-
&stores,
323-
&chain,
324-
manifest_info,
325-
mock_files,
326-
unresolved_tx,
327-
)
328-
.await?;
343+
let mock_data = MockData {
344+
ipfs_files: mock_files,
345+
ipfs_unresolved_tx: unresolved_tx,
346+
arweave_files: mock_arweave_files,
347+
arweave_unresolved_tx,
348+
};
349+
350+
let ctx = setup_context(&logger, &stores, &chain, manifest_info, mock_data).await?;
329351

330352
// Populate eth_call cache with mock responses before starting indexer.
331353
// This ensures handlers can successfully retrieve mocked contract call results.
@@ -362,25 +384,51 @@ pub async fn run_single_test(
362384
Ok(()) => {
363385
// Drain any CIDs that were requested but not found in the mock.
364386
// Deduplicate so each missing CID is listed once.
365-
let mut unresolved: Vec<ContentPath> = Vec::new();
387+
let mut unresolved_ipfs: Vec<ContentPath> = Vec::new();
366388
while let Ok(cid) = unresolved_rx.try_recv() {
367-
if !unresolved.contains(&cid) {
368-
unresolved.push(cid);
389+
if !unresolved_ipfs.contains(&cid) {
390+
unresolved_ipfs.push(cid);
369391
}
370392
}
371393

372-
if !unresolved.is_empty() {
373-
let cid_list = unresolved
394+
let mut unresolved_arweave: Vec<String> = Vec::new();
395+
while let Ok(tx_id) = arweave_unresolved_rx.try_recv() {
396+
if !unresolved_arweave.contains(&tx_id) {
397+
unresolved_arweave.push(tx_id);
398+
}
399+
}
400+
401+
let mut missing_parts: Vec<String> = Vec::new();
402+
403+
if !unresolved_ipfs.is_empty() {
404+
let list = unresolved_ipfs
374405
.iter()
375406
.map(|p| format!(" - {}", p))
376407
.collect::<Vec<_>>()
377408
.join("\n");
409+
missing_parts.push(format!(
410+
"IPFS CIDs not found in mock 'files':\n{}\n\
411+
Add the missing CID(s) to the \"files\" array in your test JSON.",
412+
list
413+
));
414+
}
415+
416+
if !unresolved_arweave.is_empty() {
417+
let list = unresolved_arweave
418+
.iter()
419+
.map(|id| format!(" - {}", id))
420+
.collect::<Vec<_>>()
421+
.join("\n");
422+
missing_parts.push(format!(
423+
"Arweave tx IDs not found in mock 'arweaveFiles':\n{}\n\
424+
Add the missing txId(s) to the \"arweaveFiles\" array in your test JSON.",
425+
list
426+
));
427+
}
428+
429+
if !missing_parts.is_empty() {
378430
Ok(TestResult {
379-
handler_error: Some(format!(
380-
"File data source requested CID not found in mock 'files':\n{}\n\
381-
Add the missing CID(s) to the \"files\" array in your test JSON.",
382-
cid_list
383-
)),
431+
handler_error: Some(missing_parts.join("\n\n")),
384432
assertions: vec![],
385433
})
386434
} else {
@@ -670,8 +718,7 @@ async fn setup_context(
670718
stores: &TestStores,
671719
chain: &Arc<Chain>,
672720
manifest_info: &ManifestInfo,
673-
mock_files: HashMap<ContentPath, graph::bytes::Bytes>,
674-
unresolved_tx: tokio::sync::mpsc::UnboundedSender<ContentPath>,
721+
mock_data: MockData,
675722
) -> Result<TestContext> {
676723
let build_dir = &manifest_info.build_dir;
677724
let manifest_path = &manifest_info.manifest_path;
@@ -705,9 +752,9 @@ async fn setup_context(
705752
// FileLinkResolver handles manifest loading; the mock handles file data sources.
706753
let ipfs_metrics = IpfsMetrics::new(&mock_registry);
707754
let ipfs_client = Arc::new(MockIpfsClient {
708-
files: mock_files,
755+
files: mock_data.ipfs_files,
709756
metrics: ipfs_metrics,
710-
unresolved_tx,
757+
unresolved_tx: mock_data.ipfs_unresolved_tx,
711758
});
712759

713760
let ipfs_service = ipfs_service(
@@ -717,9 +764,13 @@ async fn setup_context(
717764
env_vars.mappings.ipfs_request_limit,
718765
);
719766

720-
let arweave_resolver = Arc::new(ArweaveClient::default());
767+
let arweave_resolver: Arc<dyn graph::components::link_resolver::ArweaveResolver> =
768+
Arc::new(MockArweaveResolver {
769+
files: mock_data.arweave_files,
770+
unresolved_tx: mock_data.arweave_unresolved_tx,
771+
});
721772
let arweave_service = arweave_service(
722-
arweave_resolver.cheap_clone(),
773+
arweave_resolver,
723774
env_vars.mappings.ipfs_request_limit,
724775
graph::components::link_resolver::FileSizeLimit::MaxBytes(
725776
env_vars.mappings.max_ipfs_file_bytes as u64,

gnd/src/commands/test/schema.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ pub struct TestFile {
4444
#[serde(default)]
4545
pub files: Vec<MockFile>,
4646

47+
/// Mock Arweave file contents keyed by transaction ID. Used for file/arweave data sources.
48+
#[serde(default, rename = "arweaveFiles")]
49+
pub arweave_files: Vec<MockArweaveFile>,
50+
4751
/// Ordered sequence of mock blocks to index.
4852
#[serde(default)]
4953
pub blocks: Vec<TestBlock>,
@@ -74,6 +78,59 @@ pub struct MockFile {
7478
pub file: Option<String>,
7579
}
7680

81+
/// A mock Arweave file entry for file/arweave data source testing.
82+
///
83+
/// Exactly one of `content` or `file` must be set.
84+
#[derive(Debug, Clone, Deserialize)]
85+
pub struct MockArweaveFile {
86+
/// Arweave transaction ID or bundle path (e.g. `"txid/filename.json"`).
87+
/// No format validation — treated as an opaque string key.
88+
#[serde(rename = "txId")]
89+
pub tx_id: String,
90+
91+
/// Inline UTF-8 content. Exactly one of `content` or `file` must be set.
92+
#[serde(default)]
93+
pub content: Option<String>,
94+
95+
/// Path to a file. Resolved relative to the test JSON file.
96+
/// Exactly one of `content` or `file` must be set.
97+
#[serde(default)]
98+
pub file: Option<String>,
99+
}
100+
101+
impl MockArweaveFile {
102+
/// Resolve this entry to bytes, given the directory of the test JSON file.
103+
///
104+
/// Fails if:
105+
/// - neither `content` nor `file` is set
106+
/// - both `content` and `file` are set
107+
/// - the referenced `file` path cannot be read
108+
pub fn resolve(&self, test_dir: &Path) -> anyhow::Result<graph::bytes::Bytes> {
109+
match (&self.content, &self.file) {
110+
(Some(content), None) => Ok(graph::bytes::Bytes::from(content.clone().into_bytes())),
111+
(None, Some(file)) => {
112+
let path = if Path::new(file).is_absolute() {
113+
PathBuf::from(file)
114+
} else {
115+
test_dir.join(file)
116+
};
117+
let data = std::fs::read(&path).map_err(|e| {
118+
anyhow::anyhow!("Failed to read file '{}': {}", path.display(), e)
119+
})?;
120+
Ok(graph::bytes::Bytes::from(data))
121+
}
122+
(Some(_), Some(_)) => anyhow::bail!(
123+
"MockArweaveFile entry for txId '{}' must have either 'content' or 'file', not both",
124+
self.tx_id
125+
),
126+
(None, None) => anyhow::bail!(
127+
"MockArweaveFile entry for txId '{}' must have either 'content' or 'file'",
128+
self.tx_id
129+
),
130+
}
131+
}
132+
}
133+
77134
impl MockFile {
78135
/// Resolve this entry to bytes, given the directory of the test JSON file.
79136
///

0 commit comments

Comments
 (0)