use std::path::PathBuf;
#[cfg(feature = "test-helpers")]
use std::sync::Mutex;
#[derive(Debug, Clone)]
pub struct ReleaseInfo {
pub id: u64,
pub html_url: String,
pub tag_name: String,
pub name: Option<String>,
pub draft: bool,
}
#[derive(Debug, Clone)]
pub struct CreateReleaseParams {
pub owner: String,
pub repo: String,
pub tag_name: String,
pub name: String,
pub body: String,
pub draft: bool,
pub prerelease: bool,
pub generate_release_notes: bool,
pub make_latest: Option<String>,
}
#[derive(Debug, Clone)]
pub struct UploadAssetParams {
pub owner: String,
pub repo: String,
pub release_id: u64,
pub file_name: String,
pub file_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct AssetInfo {
pub id: u64,
pub name: String,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct ListReleasesParams {
pub owner: String,
pub repo: String,
}
#[derive(Debug, Clone)]
pub struct DeleteReleaseParams {
pub owner: String,
pub repo: String,
pub release_id: u64,
}
pub trait GitHubClient {
fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo>;
fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo>;
fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>>;
fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()>;
}
#[cfg(feature = "test-helpers")]
pub struct MockGitHubClient {
create_release_calls: Mutex<Vec<CreateReleaseParams>>,
upload_asset_calls: Mutex<Vec<UploadAssetParams>>,
list_releases_calls: Mutex<Vec<ListReleasesParams>>,
delete_release_calls: Mutex<Vec<DeleteReleaseParams>>,
create_release_response: Mutex<Option<Result<ReleaseInfo, String>>>,
upload_asset_response: Mutex<Option<Result<AssetInfo, String>>>,
list_releases_response: Mutex<Option<Result<Vec<ReleaseInfo>, String>>>,
delete_release_response: Mutex<Option<Result<(), String>>>,
}
#[cfg(feature = "test-helpers")]
impl MockGitHubClient {
pub fn new() -> Self {
Self {
create_release_calls: Mutex::new(Vec::new()),
upload_asset_calls: Mutex::new(Vec::new()),
list_releases_calls: Mutex::new(Vec::new()),
delete_release_calls: Mutex::new(Vec::new()),
create_release_response: Mutex::new(None),
upload_asset_response: Mutex::new(None),
list_releases_response: Mutex::new(None),
delete_release_response: Mutex::new(None),
}
}
pub fn set_create_release_response(&self, response: Result<ReleaseInfo, String>) {
*self
.create_release_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
}
pub fn set_upload_asset_response(&self, response: Result<AssetInfo, String>) {
*self
.upload_asset_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
}
pub fn set_list_releases_response(&self, response: Result<Vec<ReleaseInfo>, String>) {
*self
.list_releases_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
}
pub fn set_delete_release_response(&self, response: Result<(), String>) {
*self
.delete_release_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
}
pub fn create_release_call_count(&self) -> usize {
self.create_release_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn upload_asset_call_count(&self) -> usize {
self.upload_asset_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn list_releases_call_count(&self) -> usize {
self.list_releases_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn delete_release_call_count(&self) -> usize {
self.delete_release_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn create_release_calls(&self) -> Vec<CreateReleaseParams> {
self.create_release_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn upload_asset_calls(&self) -> Vec<UploadAssetParams> {
self.upload_asset_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn list_releases_calls(&self) -> Vec<ListReleasesParams> {
self.list_releases_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn delete_release_calls(&self) -> Vec<DeleteReleaseParams> {
self.delete_release_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
#[cfg(feature = "test-helpers")]
impl Default for MockGitHubClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "test-helpers")]
impl GitHubClient for MockGitHubClient {
fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo> {
self.create_release_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(params.clone());
match self
.create_release_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
Some(Ok(info)) => Ok(info.clone()),
Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
None => Err(anyhow::anyhow!(
"MockGitHubClient: no create_release response configured"
)),
}
}
fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo> {
self.upload_asset_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(params.clone());
match self
.upload_asset_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
Some(Ok(info)) => Ok(info.clone()),
Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
None => Err(anyhow::anyhow!(
"MockGitHubClient: no upload_asset response configured"
)),
}
}
fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>> {
self.list_releases_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(params.clone());
match self
.list_releases_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
Some(Ok(releases)) => Ok(releases.clone()),
Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
None => Err(anyhow::anyhow!(
"MockGitHubClient: no list_releases response configured"
)),
}
}
fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()> {
self.delete_release_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(params.clone());
match self
.delete_release_response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
Some(Ok(())) => Ok(()),
Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
None => Err(anyhow::anyhow!(
"MockGitHubClient: no delete_release response configured"
)),
}
}
}
#[cfg(all(test, feature = "test-helpers"))]
mod tests {
use super::*;
#[test]
fn test_mock_records_create_release_calls() {
let mock = MockGitHubClient::new();
mock.set_create_release_response(Ok(ReleaseInfo {
id: 42,
html_url: "https://github.com/owner/repo/releases/42".to_string(),
tag_name: "v1.0.0".to_string(),
name: Some("Release v1.0.0".to_string()),
draft: false,
}));
let params = CreateReleaseParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
tag_name: "v1.0.0".to_string(),
name: "Release v1.0.0".to_string(),
body: "Changelog here".to_string(),
draft: false,
prerelease: false,
generate_release_notes: false,
make_latest: None,
};
let result = mock.create_release(¶ms).unwrap();
assert_eq!(result.id, 42);
assert_eq!(result.tag_name, "v1.0.0");
assert_eq!(mock.create_release_call_count(), 1);
let calls = mock.create_release_calls();
assert_eq!(calls[0].owner, "owner");
assert_eq!(calls[0].tag_name, "v1.0.0");
}
#[test]
fn test_mock_records_upload_asset_calls() {
let mock = MockGitHubClient::new();
mock.set_upload_asset_response(Ok(AssetInfo {
id: 100,
name: "myapp-linux-amd64.tar.gz".to_string(),
size: 4096,
}));
let params = UploadAssetParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
release_id: 42,
file_name: "myapp-linux-amd64.tar.gz".to_string(),
file_path: PathBuf::from("/tmp/myapp-linux-amd64.tar.gz"),
};
let result = mock.upload_asset(¶ms).unwrap();
assert_eq!(result.name, "myapp-linux-amd64.tar.gz");
assert_eq!(mock.upload_asset_call_count(), 1);
}
#[test]
fn test_mock_records_list_releases_calls() {
let mock = MockGitHubClient::new();
mock.set_list_releases_response(Ok(vec![ReleaseInfo {
id: 1,
html_url: "https://github.com/owner/repo/releases/1".to_string(),
tag_name: "v0.9.0".to_string(),
name: Some("Release v0.9.0".to_string()),
draft: false,
}]));
let params = ListReleasesParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
};
let result = mock.list_releases(¶ms).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(mock.list_releases_call_count(), 1);
}
#[test]
fn test_mock_records_delete_release_calls() {
let mock = MockGitHubClient::new();
mock.set_delete_release_response(Ok(()));
let params = DeleteReleaseParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
release_id: 42,
};
mock.delete_release(¶ms).unwrap();
assert_eq!(mock.delete_release_call_count(), 1);
let calls = mock.delete_release_calls();
assert_eq!(calls[0].release_id, 42);
}
#[test]
fn test_mock_returns_error_when_no_response_configured() {
let mock = MockGitHubClient::new();
let params = CreateReleaseParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
tag_name: "v1.0.0".to_string(),
name: "Release".to_string(),
body: "".to_string(),
draft: false,
prerelease: false,
generate_release_notes: false,
make_latest: None,
};
let result = mock.create_release(¶ms);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no create_release response configured")
);
}
#[test]
fn test_mock_returns_configured_error() {
let mock = MockGitHubClient::new();
mock.set_create_release_response(Err("API rate limit exceeded".to_string()));
let params = CreateReleaseParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
tag_name: "v1.0.0".to_string(),
name: "Release".to_string(),
body: "".to_string(),
draft: false,
prerelease: false,
generate_release_notes: false,
make_latest: None,
};
let result = mock.create_release(¶ms);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("API rate limit exceeded")
);
}
#[test]
fn test_mock_multiple_calls_accumulate() {
let mock = MockGitHubClient::new();
mock.set_delete_release_response(Ok(()));
for i in 1..=3 {
let params = DeleteReleaseParams {
owner: "owner".to_string(),
repo: "repo".to_string(),
release_id: i,
};
mock.delete_release(¶ms).unwrap();
}
assert_eq!(mock.delete_release_call_count(), 3);
let calls = mock.delete_release_calls();
assert_eq!(calls[0].release_id, 1);
assert_eq!(calls[1].release_id, 2);
assert_eq!(calls[2].release_id, 3);
}
#[test]
fn test_mock_default_is_same_as_new() {
let mock = MockGitHubClient::default();
assert_eq!(mock.create_release_call_count(), 0);
assert_eq!(mock.upload_asset_call_count(), 0);
assert_eq!(mock.list_releases_call_count(), 0);
assert_eq!(mock.delete_release_call_count(), 0);
}
}