konservejo/tests/common/mod.rs
NotAShelf 133d392df7
tests: initial integration test setup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I082de335ed2b2bf53d687d81db0901ef6a6a6964
2026-03-19 17:01:09 +03:00

230 lines
5.8 KiB
Rust

//! 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<u8>,
}
impl MockRepo {
pub fn new(owner: &str, name: &str, archive_bytes: Vec<u8>) -> 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<MockRepo>) -> (MockServer, String) {
let server = MockServer::start().await;
// Group repos by organization
let mut org_repos: HashMap<String, Vec<MockRepo>> = 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<serde_json::Value> = 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::<Vec<_>>()
.join(", ");
let excludes_str = excludes
.iter()
.map(|e| format!("\"{e}\""))
.collect::<Vec<_>>()
.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<u8> {
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> {
konservejo::config::Config::from_file(ctx.config_path())
}