diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1add8d4..d54df0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,3 +19,4 @@ jobs: - uses: actions/checkout@v3 - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - run: cargo build --verbose + - run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index 7deadfe..b4a8eac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dev-dependencies] +mockall = "0.13" + [dependencies] actix-governor = "0.5" actix-web = "4.8" diff --git a/src/app_data.rs b/src/app_data.rs index a4cb717..67f6e24 100644 --- a/src/app_data.rs +++ b/src/app_data.rs @@ -1,10 +1,12 @@ use cached::TimedCache; +use octocrab::Octocrab; use tokio::sync::Mutex; use crate::fetcher::Fetcher; +use crate::fetcher::HttpChecksumFetcher; use crate::routes::version::CachedReleased; pub struct AppData { pub cache: Mutex>, - pub fetcher: Fetcher, + pub fetcher: Fetcher, } diff --git a/src/fetcher/checksum.rs b/src/fetcher/checksum.rs new file mode 100644 index 0000000..f8f1db1 --- /dev/null +++ b/src/fetcher/checksum.rs @@ -0,0 +1,45 @@ +#[cfg(test)] +use mockall::automock; + +use crate::errors::{InternalError, Result}; +use crate::game_data::Asset; + +#[cfg_attr(test, automock)] +pub trait ChecksumFetcher { + async fn resolve_asset(&self, asset: &Asset) -> Result; +} + +pub struct HttpChecksumFetcher(reqwest::Client); + +impl HttpChecksumFetcher { + pub fn new() -> Self { + Self(reqwest::Client::new()) + } + + fn parse_response(&self, asset_name: &str, response: &str) -> Result { + let parts: Vec<_> = response.split_whitespace().collect(); + if parts.len() != 2 { + return Err(InternalError::InvalidSha256(parts.len())); + } + + let (sha256, filename) = (parts[0], parts[1]); + match !filename.starts_with('*') || &filename[1..] != asset_name { + false => Ok(sha256.to_string()), + true => Err(InternalError::WrongChecksum), + } + } +} + +impl ChecksumFetcher for HttpChecksumFetcher { + async fn resolve_asset(&self, asset: &Asset) -> Result { + let response = self + .0 + .get(format!("{}.sha256", asset.download_url)) + .send() + .await? + .text() + .await?; + + self.parse_response(asset.name.as_str(), response.as_str()) + } +} diff --git a/src/fetcher.rs b/src/fetcher/mod.rs similarity index 71% rename from src/fetcher.rs rename to src/fetcher/mod.rs index deca73e..68fb277 100644 --- a/src/fetcher.rs +++ b/src/fetcher/mod.rs @@ -1,50 +1,55 @@ +pub use checksum::{ChecksumFetcher, HttpChecksumFetcher}; use futures::future::join_all; use octocrab::models::repos; -use octocrab::repos::RepoHandler; use octocrab::{Octocrab, OctocrabBuilder}; +pub use repo::RepoFetcher; use semver::Version; use crate::config::ApiConfig; use crate::errors::{InternalError, Result}; use crate::game_data::{Asset, Assets, GameRelease, Repo}; -pub struct Fetcher { - octocrab: Octocrab, +mod checksum; +mod repo; +#[cfg(test)] +mod tests; + +pub struct Fetcher { game_repo: Repo, updater_repo: Repo, - checksum_fetcher: ChecksumFetcher, + repo_fetcher: F, + checksum_fetcher: C, } -struct ChecksumFetcher(reqwest::Client); - -impl Fetcher { +impl Fetcher { pub fn from_config(config: &ApiConfig) -> Result { let mut octocrab = OctocrabBuilder::default(); if let Some(github_pat) = &config.github_pat { octocrab = octocrab.personal_token(github_pat.unsecure().to_string()); } - Ok(Self { - octocrab: octocrab.build()?, - game_repo: Repo::new(&config.repo_owner, &config.game_repository), - updater_repo: Repo::new(&config.repo_owner, &config.updater_repository), - - checksum_fetcher: ChecksumFetcher::new(), - }) + Ok(Self::new( + Repo::new(&config.repo_owner, &config.game_repository), + Repo::new(&config.repo_owner, &config.updater_repository), + octocrab.build()?, + HttpChecksumFetcher::new(), + )) } +} - fn on_repo(&self, repo: &Repo) -> RepoHandler<'_> { - self.octocrab.repos(repo.owner(), repo.repository()) +impl Fetcher { + pub fn new(game_repo: Repo, updater_repo: Repo, repo_fetcher: F, checksum_fetcher: C) -> Self { + Self { + game_repo, + updater_repo, + repo_fetcher, + checksum_fetcher, + } } pub async fn get_latest_game_release(&self) -> Result { - let releases = self - .on_repo(&self.game_repo) - .releases() - .list() - .send() - .await?; + let releases = self.repo_fetcher.get_releases(&self.game_repo).await?; let mut versions_released = releases .into_iter() @@ -103,9 +108,8 @@ impl Fetcher { pub async fn get_latest_updater_release(&self) -> Result { let last_release = self - .on_repo(&self.updater_repo) - .releases() - .get_latest() + .repo_fetcher + .get_last_release(&self.updater_repo) .await?; let version = Version::parse(&last_release.tag_name)?; @@ -151,7 +155,7 @@ impl Fetcher { let checksums = join_all( assets .iter() - .map(|(_, asset)| self.checksum_fetcher.resolve(asset)), + .map(|(_, asset)| self.checksum_fetcher.resolve_asset(asset)), ) .await; @@ -159,36 +163,6 @@ impl Fetcher { } } -impl ChecksumFetcher { - fn new() -> Self { - Self(reqwest::Client::new()) - } - - async fn resolve(&self, asset: &Asset) -> Result { - let response = self - .0 - .get(format!("{}.sha256", asset.download_url)) - .send() - .await? - .text() - .await?; - self.parse_response(asset.name.as_str(), response.as_str()) - } - - fn parse_response(&self, asset_name: &str, response: &str) -> Result { - let parts: Vec<_> = response.split_whitespace().collect(); - if parts.len() != 2 { - return Err(InternalError::InvalidSha256(parts.len())); - } - - let (sha256, filename) = (parts[0], parts[1]); - match !filename.starts_with('*') || &filename[1..] != asset_name { - false => Ok(sha256.to_string()), - true => Err(InternalError::WrongChecksum), - } - } -} - fn remove_game_suffix(asset_name: &str) -> &str { let platform = asset_name .find('.') diff --git a/src/fetcher/repo.rs b/src/fetcher/repo.rs new file mode 100644 index 0000000..2388153 --- /dev/null +++ b/src/fetcher/repo.rs @@ -0,0 +1,37 @@ +#[cfg(test)] +use mockall::automock; +use octocrab::models::repos::Release; +use octocrab::Octocrab; + +use crate::errors::{InternalError, Result}; +use crate::game_data::Repo; + +#[cfg_attr(test, automock(type Pager = Vec;))] +pub trait RepoFetcher { + type Pager: IntoIterator; + + async fn get_releases(&self, repo: &Repo) -> Result; + async fn get_last_release(&self, repo: &Repo) -> Result; +} + +impl RepoFetcher for Octocrab { + type Pager = std::vec::IntoIter; + + async fn get_releases(&self, repo: &Repo) -> Result<::Pager> { + Ok(self + .repos(repo.owner(), repo.repository()) + .releases() + .list() + .send() + .await? + .into_iter()) + } + + async fn get_last_release(&self, repo: &Repo) -> Result { + self.repos(repo.owner(), repo.repository()) + .releases() + .get_latest() + .await + .map_err(|err| InternalError::External(Box::new(err))) + } +} diff --git a/src/fetcher/tests.rs b/src/fetcher/tests.rs new file mode 100644 index 0000000..cc3a378 --- /dev/null +++ b/src/fetcher/tests.rs @@ -0,0 +1,376 @@ +use std::collections::HashMap; + +use mockall::predicate::eq; +use octocrab::models::repos::{Asset as RepoAsset, Release}; +use octocrab::models::{AssetId, ReleaseId}; +use semver::Version; +use url::Url; + +use crate::errors::Result; +use crate::game_data::{Asset, GameRelease, Repo}; + +use super::checksum::MockChecksumFetcher; +use super::repo::MockRepoFetcher; +use super::Fetcher; + +#[tokio::test] +async fn retrieve_the_latest_version_of_the_updater_when_there_is_only_one_available() -> Result<()> +{ + let updater_repo: Repo = Repo::new("repo", "updater"); + let game_repo: Repo = Repo::new("repo", "game"); + + let mut repo_fetcher = MockRepoFetcher::new(); + let mut checksum_fetcher = MockChecksumFetcher::new(); + + let windows_asset = |sha256| Asset { + size: 1_000_000, + name: "windows_x64_releasedbg.zip".to_string(), + version: Version::new(0, 1, 0), + download_url: "http://github.com/repo/updater/releases/0.1.0/windows_x64_releasedbg.zip" + .to_string(), + sha256, + }; + + repo_fetcher + .expect_get_last_release() + .with(eq(updater_repo.clone())) + .times(1) + .returning(|repo| { + release_builder(|release| { + release.tag_name = "0.1.0".to_string(); + release.assets = vec![ + asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "windows_x64_releasedbg.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + asset_builder(|asset| { + asset.size = 93; + asset.name = "windows_x64_releasedbg.zip.sha256".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + ]; + + Ok(()) + }) + }); + + checksum_fetcher + .expect_resolve_asset() + .with(eq(windows_asset(None))) + .times(1) + .returning(|_| Ok("*sha256-key*".to_string())); + + let fetcher = Fetcher::new(game_repo, updater_repo, repo_fetcher, checksum_fetcher); + + let latest_releases = fetcher.get_latest_updater_release().await.expect("fail :("); + + assert_eq!( + latest_releases, + HashMap::from_iter([( + "windows_x64".to_string(), + windows_asset(Some("*sha256-key*".to_string())) + )]) + ); + + Ok(()) +} + +#[tokio::test] +async fn retrieve_the_latest_version_of_the_game_when_there_is_only_one_available() -> Result<()> { + let updater_repo: Repo = Repo::new("repo", "updater"); + let game_repo: Repo = Repo::new("repo", "game"); + + let mut repo_fetcher = MockRepoFetcher::new(); + let mut checksum_fetcher = MockChecksumFetcher::new(); + let asset = |name: &str, version: Version, sha256: Option<&str>| Asset { + size: 1_000_000, + name: name.to_string(), + download_url: format!( + "http://github.com/repo/game/releases/{}/{}", + version.to_string(), + name + ), + version, + sha256: sha256.map(str::to_string), + }; + + repo_fetcher + .expect_get_releases() + .with(eq(game_repo.clone())) + .times(1) + .returning(|repo| { + Ok(vec![release_builder(|release| { + release.tag_name = "0.1.0".to_string(); + release.assets = vec![ + asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "assets.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "windows_x64_releasedbg.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + ]; + + Ok(()) + })?]) + }); + + checksum_fetcher + .expect_resolve_asset() + .with(eq(asset("assets.zip", Version::new(0, 1, 0), None))) + .times(1) + .returning(|_| Ok("*sha256-key*".to_string())); + + checksum_fetcher + .expect_resolve_asset() + .with(eq(asset( + "windows_x64_releasedbg.zip", + Version::new(0, 1, 0), + None, + ))) + .times(1) + .returning(|_| Ok("*sha256-key*".to_string())); + + let fetcher = Fetcher::new(game_repo, updater_repo, repo_fetcher, checksum_fetcher); + + let latest_releases = fetcher.get_latest_game_release().await.expect("fail :("); + + assert_eq!( + latest_releases, + GameRelease { + assets: asset("assets.zip", Version::new(0, 1, 0), Some("*sha256-key*")), + assets_version: Version::new(0, 1, 0), + version: Version::new(0, 1, 0), + binaries: HashMap::from_iter([( + "windows_x64".to_string(), + asset( + "windows_x64_releasedbg.zip", + Version::new(0, 1, 0), + Some("*sha256-key*") + ) + )]) + } + ); + + Ok(()) +} + +#[tokio::test] +async fn retrieve_the_latest_version_of_the_game_during_population_of_the_latest_release( +) -> Result<()> { + let updater_repo: Repo = Repo::new("repo", "updater"); + let game_repo: Repo = Repo::new("repo", "game"); + + let mut repo_fetcher = MockRepoFetcher::new(); + let mut checksum_fetcher = MockChecksumFetcher::new(); + let asset = |name: &str, version: Version, sha256: Option<&str>| Asset { + size: 1_000_000, + name: name.to_string(), + download_url: format!( + "http://github.com/repo/game/releases/{}/{}", + version.to_string(), + name + ), + version, + sha256: sha256.map(str::to_string), + }; + + let mut expect_resolve_asset = |name: &str, version: Version| { + checksum_fetcher + .expect_resolve_asset() + .with(eq(asset(name, version, None))) + .times(1) + .returning(|_| Ok("*sha256-key*".to_string())); + }; + + repo_fetcher + .expect_get_releases() + .with(eq(game_repo.clone())) + .times(1) + .returning(|repo| { + Ok(vec![ + release_builder(|release| { + release.tag_name = "0.2.0".to_string(); + release.assets = vec![asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "windows_x64_releasedbg.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.2.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?]; + + Ok(()) + })?, + release_builder(|release| { + release.tag_name = "0.1.0".to_string(); + release.assets = vec![ + asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "assets.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "windows_x64_releasedbg.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + asset_builder(|asset| { + asset.size = 1_000_000; + asset.name = "linux_x86_64_releasedbg.zip".to_string(); + asset.browser_download_url = Url::parse(&format!( + "http://github.com/{}/{}/releases/0.1.0/{}", + repo.owner(), + repo.repository(), + asset.name + ))?; + + Ok(()) + })?, + ]; + + Ok(()) + })?, + ]) + }); + + expect_resolve_asset("windows_x64_releasedbg.zip", Version::new(0, 2, 0)); + expect_resolve_asset("assets.zip", Version::new(0, 1, 0)); + expect_resolve_asset("linux_x86_64_releasedbg.zip", Version::new(0, 1, 0)); + + let fetcher = Fetcher::new(game_repo, updater_repo, repo_fetcher, checksum_fetcher); + + let latest_releases = fetcher.get_latest_game_release().await.expect("fail :("); + + assert_eq!( + latest_releases, + GameRelease { + assets: asset("assets.zip", Version::new(0, 1, 0), Some("*sha256-key*")), + assets_version: Version::new(0, 1, 0), + version: Version::new(0, 2, 0), + binaries: HashMap::from_iter([ + ( + "windows_x64".to_string(), + asset( + "windows_x64_releasedbg.zip", + Version::new(0, 2, 0), + Some("*sha256-key*") + ) + ), + ( + "linux_x86_64".to_string(), + asset( + "linux_x86_64_releasedbg.zip", + Version::new(0, 1, 0), + Some("*sha256-key*") + ) + ) + ]) + } + ); + + Ok(()) +} + +fn asset_builder(builder: B) -> Result +where + B: FnOnce(&mut RepoAsset) -> Result<()>, +{ + let mut asset = RepoAsset { + url: Url::parse("http://exemple.com")?, + browser_download_url: Url::parse("http://exemple.com")?, + id: AssetId(0), + node_id: String::new(), + name: String::new(), + label: None, + state: String::new(), + content_type: String::new(), + size: 0, + download_count: 0, + created_at: Default::default(), + updated_at: Default::default(), + uploader: None, + }; + + builder(&mut asset)?; + Ok(asset) +} + +fn release_builder(builder: B) -> Result +where + B: FnOnce(&mut Release) -> Result<()>, +{ + let mut release = Release { + url: Url::parse("http://exemple.com")?, + html_url: Url::parse("http://exemple.com")?, + assets_url: Url::parse("http://exemple.com")?, + upload_url: String::new(), + tarball_url: None, + zipball_url: None, + id: ReleaseId(0), + node_id: String::new(), + tag_name: String::new(), + target_commitish: String::new(), + name: None, + body: None, + draft: false, + prerelease: false, + created_at: None, + published_at: None, + author: None, + assets: Vec::new(), + }; + + builder(&mut release)?; + Ok(release) +} diff --git a/src/game_data.rs b/src/game_data.rs index b801f65..4b58ffb 100644 --- a/src/game_data.rs +++ b/src/game_data.rs @@ -5,6 +5,7 @@ use semver::Version; use serde::Serialize; #[derive(Clone, Serialize)] +#[cfg_attr(test, derive(Debug, PartialEq))] pub struct Asset { pub size: i64, // serialisation skipped to race with the previous api @@ -16,6 +17,7 @@ pub struct Asset { pub sha256: Option, } +#[cfg_attr(test, derive(Debug, PartialEq, Clone))] pub struct Repo { owner: String, repository: String, @@ -24,6 +26,7 @@ pub struct Repo { pub type Assets = HashMap; #[derive(Clone)] +#[cfg_attr(test, derive(Debug, PartialEq))] pub struct GameRelease { pub assets: Asset, pub assets_version: Version, diff --git a/src/metaprog.rs b/src/metaprog.rs index ab6554f..690622b 100644 --- a/src/metaprog.rs +++ b/src/metaprog.rs @@ -4,3 +4,10 @@ use std::any::TypeId; pub fn type_eq() -> bool { TypeId::of::() == TypeId::of::() } + +#[test] +fn test_type_eq() { + assert!(type_eq::()); + assert!(!type_eq::<&str, String>()); + assert!(!type_eq::()); +}