//! Common test utilities for integration tests. #![expect(clippy::expect_used)] use std::{collections::HashMap, path::Path}; use tempfile::{NamedTempFile, TempDir}; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{header, method, path_regex}, }; /// Configuration for a mock repository. #[derive(Debug, Clone)] pub struct MockRepo { pub owner: String, pub name: String, pub archive_bytes: Vec, } impl MockRepo { pub fn new(owner: &str, name: &str, archive_bytes: Vec) -> Self { Self { owner: owner.to_string(), name: name.to_string(), archive_bytes, } } } /// Sets up a mock Forgejo server with the given repositories. pub async fn setup_mock_server(repos: Vec) -> (MockServer, String) { let server = MockServer::start().await; // Group repos by organization let mut org_repos: HashMap> = HashMap::new(); for repo in repos { org_repos.entry(repo.owner.clone()).or_default().push(repo); } // Set up organization repo listing endpoints for (org, repos) in org_repos { let repos_clone = repos.clone(); Mock::given(method("GET")) .and(path_regex(format!(r"/orgs/{}/repos", regex::escape(&org)))) .and(header("authorization", "token test-token")) .and(header("accept", "application/json")) .respond_with(move |_req: &wiremock::Request| { let api_repos: Vec = repos_clone .iter() .map(|r| { serde_json::json!({ "name": r.name, "default_branch": "main", "owner": {"login": r.owner} }) }) .collect(); ResponseTemplate::new(200).set_body_json(api_repos) }) .mount(&server) .await; // Set up archive download endpoints for each repo for repo in repos { let archive_bytes = repo.archive_bytes.clone(); Mock::given(method("GET")) .and(path_regex(format!( r"/repos/{}/{}/archive/.*", regex::escape(&repo.owner), regex::escape(&repo.name) ))) .and(header("authorization", "token test-token")) .respond_with(move |_req: &wiremock::Request| { ResponseTemplate::new(200) .set_body_bytes(archive_bytes.clone()) .insert_header("content-type", "application/gzip") }) .mount(&server) .await; } } let url = server.uri(); (server, url) } /// Test context holding all temporary resources. pub struct TestContext { temp_dir: TempDir, sink_dir: TempDir, db_file: NamedTempFile, config_file: NamedTempFile, mock_url: String, } impl TestContext { /// Creates a new test context with the given mock server URL. pub fn new(mock_url: String) -> Self { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let sink_dir = TempDir::new().expect("Failed to create sink dir"); let db_file = NamedTempFile::new().expect("Failed to create temp db file"); let config_file = NamedTempFile::new().expect("Failed to create temp config file"); Self { temp_dir, sink_dir, db_file, config_file, mock_url, } } /// Writes a TOML configuration file for the test. pub fn write_config( &self, orgs: &[String], excludes: &[String], concurrency: usize, max_retries: u32, backoff_ms: u64, ) { let orgs_list = orgs .iter() .map(|o| format!("\"{o}\"")) .collect::>() .join(", "); let excludes_str = excludes .iter() .map(|e| format!("\"{e}\"")) .collect::>() .join(", "); let config = format!( r#"[service] name = "test" temp_dir = "{}" state_db_path = "{}" concurrency_limit = {concurrency} [service.retry] max_retries = {max_retries} initial_backoff_ms = {backoff_ms} [[source]] type = "forgejo" id = "test-source" api_url = "{}" token = "test-token" [source.scope] organizations = [{orgs_list}] exclude_repos = [{excludes_str}] [[sink]] type = "filesystem" id = "test-sink" path = "{}" verify_on_write = true "#, self.temp_dir.path().display(), self.db_file.path().display(), self.mock_url, self.sink_dir.path().display() ); std::fs::write(self.config_file.path(), config) .expect("Failed to write config file"); } /// Returns the path to the configuration file. pub fn config_path(&self) -> &Path { self.config_file.path() } /// Returns the path to the database file. pub fn db_path(&self) -> &Path { self.db_file.path() } } /// Creates a minimal valid tar.gz archive containing a single file. pub fn create_test_archive(seed: &str) -> Vec { use std::io::Write; let mut tar_builder = tar::Builder::new(Vec::new()); let content = format!("Test content for seed: {seed}\nThis is deterministic."); let mut header = tar::Header::new_gnu(); header .set_path(format!("{seed}/test.txt")) .expect("Failed to set tar path"); header.set_size(content.len() as u64); header.set_mode(0o644); header.set_cksum(); tar_builder .append(&header, content.as_bytes()) .expect("Failed to append to tar"); let tar_data = tar_builder .into_inner() .expect("Failed to finish tar archive"); let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder .write_all(&tar_data) .expect("Failed to compress tar"); encoder.finish().expect("Failed to finish gzip") } /// Computes the blake3 hash of the given data. #[allow(dead_code)] pub fn compute_hash(data: &[u8]) -> String { blake3::hash(data).to_hex().to_string() } /// Loads the configuration from the test context. pub fn load_config( ctx: &TestContext, ) -> anyhow::Result { konservejo::config::Config::from_file(ctx.config_path()) }