use crate::backends::common::{CommonBuilderConfig, CommonConfig, RequestConfig};
use crate::backends::{collect_paginated, first_page_url, next_link, send};
use crate::http_client::{header, HttpResponse};
use crate::version::bump_is_greater;
use crate::{
errors::*,
update::{Release, ReleaseAsset, ReleaseUpdate, Releases},
};
impl ReleaseAsset {
fn from_asset_gitea(asset: &serde_json::Value) -> Result<ReleaseAsset> {
let download_url = asset["browser_download_url"]
.as_str()
.ok_or_else(|| format_err!(Error::Release, "Asset missing `browser_download_url`"))?;
let name = asset["name"]
.as_str()
.ok_or_else(|| format_err!(Error::Release, "Asset missing `name`"))?;
Ok(ReleaseAsset {
download_url: download_url.to_owned(),
name: name.to_owned(),
})
}
}
impl Release {
fn from_release_gitea(release: &serde_json::Value) -> Result<Release> {
let tag = release["tag_name"]
.as_str()
.ok_or_else(|| format_err!(Error::Release, "Release missing `tag_name`"))?;
let date = release["created_at"]
.as_str()
.ok_or_else(|| format_err!(Error::Release, "Release missing `created_at`"))?;
let name = release["name"].as_str().unwrap_or(tag);
let assets = release["assets"]
.as_array()
.ok_or_else(|| format_err!(Error::Release, "No assets found"))?;
let body = release["body"].as_str().map(String::from);
let assets = assets
.iter()
.map(ReleaseAsset::from_asset_gitea)
.collect::<Result<Vec<ReleaseAsset>>>()?;
Ok(Release {
name: name.to_owned(),
version: tag.trim_start_matches('v').to_owned(),
date: date.to_owned(),
body,
assets,
})
}
}
#[derive(Clone, Debug)]
#[must_use]
pub struct ReleaseListBuilder {
host: Option<String>,
repo_owner: Option<String>,
repo_name: Option<String>,
target: Option<String>,
auth_token: Option<String>,
request: RequestConfig,
}
impl ReleaseListBuilder {
pub fn url(&mut self, host: impl Into<String>) -> &mut Self {
self.host = Some(host.into());
self
}
pub fn repo_owner(&mut self, owner: impl Into<String>) -> &mut Self {
self.repo_owner = Some(owner.into());
self
}
pub fn repo_name(&mut self, name: impl Into<String>) -> &mut Self {
self.repo_name = Some(name.into());
self
}
pub fn filter_target(&mut self, target: impl Into<String>) -> &mut Self {
self.target = Some(target.into());
self
}
pub fn auth_token(&mut self, auth_token: impl Into<String>) -> &mut Self {
self.auth_token = Some(auth_token.into());
self
}
request_config_setters!(request);
pub fn build(&self) -> Result<ReleaseList> {
self.request.check()?;
Ok(ReleaseList {
host: if let Some(ref host) = self.host {
host.to_owned()
} else {
bail!(
Error::Config,
"`url` required (gitea has no default host; call `.url(...)`)"
)
},
repo_owner: if let Some(ref owner) = self.repo_owner {
owner.to_owned()
} else {
bail!(
Error::Config,
"`repo_owner` required (call `.repo_owner(...)`)"
)
},
repo_name: if let Some(ref name) = self.repo_name {
name.to_owned()
} else {
bail!(
Error::Config,
"`repo_name` required (call `.repo_name(...)`)"
)
},
target: self.target.clone(),
auth_token: self.auth_token.clone(),
request: self.request.clone(),
})
}
}
#[derive(Clone, Debug)]
pub struct ReleaseList {
host: String,
repo_owner: String,
repo_name: String,
target: Option<String>,
auth_token: Option<String>,
request: RequestConfig,
}
impl ReleaseList {
pub fn configure() -> ReleaseListBuilder {
ReleaseListBuilder {
host: None,
repo_owner: None,
repo_name: None,
target: None,
auth_token: None,
request: RequestConfig::default(),
}
}
pub fn fetch(&self) -> Result<Vec<Release>> {
let api_url = format!(
"{}/api/v1/repos/{}/{}/releases",
self.host, self.repo_owner, self.repo_name
);
let releases = fetch_all_releases(&api_url, self.auth_token.as_deref(), &self.request)?;
let releases = match self.target {
None => releases,
Some(ref target) => releases
.into_iter()
.filter(|r| r.has_target_asset(target))
.collect::<Vec<_>>(),
};
Ok(releases)
}
}
#[derive(Clone, Debug, Default)]
#[must_use]
pub struct UpdateBuilder {
host: Option<String>,
repo_owner: Option<String>,
repo_name: Option<String>,
common: CommonBuilderConfig,
}
impl UpdateBuilder {
pub fn new() -> Self {
Default::default()
}
pub fn url(&mut self, host: impl Into<String>) -> &mut Self {
self.host = Some(host.into());
self
}
pub fn repo_owner(&mut self, owner: impl Into<String>) -> &mut Self {
self.repo_owner = Some(owner.into());
self
}
pub fn repo_name(&mut self, name: impl Into<String>) -> &mut Self {
self.repo_name = Some(name.into());
self
}
impl_common_builder_setters!();
fn build_update(&self) -> Result<Update> {
Ok(Update {
host: if let Some(ref host) = self.host {
host.to_owned()
} else {
bail!(
Error::Config,
"`url` required (gitea has no default host; call `.url(...)`)"
)
},
repo_owner: if let Some(ref owner) = self.repo_owner {
owner.to_owned()
} else {
bail!(
Error::Config,
"`repo_owner` required (call `.repo_owner(...)`)"
)
},
repo_name: if let Some(ref name) = self.repo_name {
name.to_owned()
} else {
bail!(
Error::Config,
"`repo_name` required (call `.repo_name(...)`)"
)
},
common: self.common.build()?,
})
}
pub fn build(&self) -> Result<Box<dyn ReleaseUpdate>> {
Ok(Box::new(self.build_update()?))
}
#[cfg(feature = "async")]
pub fn build_async(&self) -> Result<Update> {
self.build_update()
}
}
#[cfg(feature = "async")]
impl Update {
impl_async_update_methods!();
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Update {
host: String,
repo_owner: String,
repo_name: String,
common: CommonConfig,
}
impl Update {
pub fn configure() -> UpdateBuilder {
UpdateBuilder::new()
}
fn releases_url(&self) -> String {
format!(
"{}/api/v1/repos/{}/{}/releases",
self.host, self.repo_owner, self.repo_name
)
}
}
impl crate::update::sealed::Sealed for Update {}
impl Update {
fn fetch_latest_release(&self) -> Result<Release> {
let api_url = self.releases_url();
let resp = send(
&api_url,
api_headers(self.common.auth_token.as_deref())?,
&self.common.request,
)?;
let json = resp.json::<serde_json::Value>()?;
let releases = json
.as_array()
.ok_or_else(|| format_err!(Error::Release, "no releases found"))?;
if releases.is_empty() {
bail!(Error::Release, "no releases found");
}
Release::from_release_gitea(&releases[0])
}
fn fetch_newer_releases(&self, current_version: &str) -> Result<Vec<Release>> {
let api_url = self.releases_url();
let releases = fetch_all_releases(
&api_url,
self.common.auth_token.as_deref(),
&self.common.request,
)?;
Ok(releases
.into_iter()
.filter(|r| bump_is_greater(current_version, &r.version).unwrap_or(false))
.collect())
}
}
impl ReleaseUpdate for Update {
fn get_latest_release(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let release = self.fetch_latest_release()?;
Ok(Releases::new(vec![release], current_version))
}
fn get_latest_releases(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let releases = self.fetch_newer_releases(¤t_version)?;
Ok(Releases::new(releases, current_version))
}
fn get_release_version(&self, ver: &str) -> Result<Release> {
let api_url = format!("{}/tags/{}", self.releases_url(), urlencoding::encode(ver));
let resp = send(
&api_url,
api_headers(self.common.auth_token.as_deref())?,
&self.common.request,
)?;
let json = resp.json::<serde_json::Value>()?;
Release::from_release_gitea(&json)
}
}
impl_update_config_accessors!(Update, {
fn api_headers(&self, auth_token: Option<&str>) -> Result<header::HeaderMap> {
api_headers(auth_token)
}
});
fn fetch_all_releases(
base_url: &str,
auth_token: Option<&str>,
req: &RequestConfig,
) -> Result<Vec<Release>> {
collect_paginated(&first_page_url(base_url), |url| {
let resp = send(url, api_headers(auth_token)?, req)?;
let headers = resp.headers().clone();
let releases = resp
.json::<serde_json::Value>()?
.as_array()
.ok_or_else(|| format_err!(Error::Release, "No releases found"))?
.iter()
.map(Release::from_release_gitea)
.collect::<Result<Vec<Release>>>()?;
Ok((releases, next_link(&headers)))
})
}
#[cfg(feature = "async")]
async fn fetch_all_releases_async(
base_url: &str,
auth_token: Option<&str>,
req: &RequestConfig,
) -> Result<Vec<Release>> {
use crate::backends::{collect_paginated_async, send_async};
let auth = auth_token.map(str::to_owned);
collect_paginated_async(&first_page_url(base_url), |url| {
let auth = auth.clone();
let req = req.clone();
async move {
let resp = send_async(&url, api_headers(auth.as_deref())?, &req).await?;
let headers = resp.headers().clone();
let releases = resp
.json::<serde_json::Value>()
.await?
.as_array()
.ok_or_else(|| format_err!(Error::Release, "No releases found"))?
.iter()
.map(Release::from_release_gitea)
.collect::<Result<Vec<Release>>>()?;
Ok((releases, next_link(&headers)))
}
})
.await
}
#[cfg(feature = "async")]
impl crate::update::AsyncFetch for Update {
async fn get_latest_release_async(&self) -> Result<Releases> {
use crate::backends::send_async;
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let api_url = self.releases_url();
let resp = send_async(
&api_url,
api_headers(self.common.auth_token.as_deref())?,
&self.common.request,
)
.await?;
let json = resp.json::<serde_json::Value>().await?;
let releases = json
.as_array()
.ok_or_else(|| format_err!(Error::Release, "no releases found"))?;
if releases.is_empty() {
bail!(Error::Release, "no releases found");
}
let release = Release::from_release_gitea(&releases[0])?;
Ok(Releases::new(vec![release], current_version))
}
async fn get_latest_releases_async(&self) -> Result<Releases> {
let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
let api_url = self.releases_url();
let releases = fetch_all_releases_async(
&api_url,
self.common.auth_token.as_deref(),
&self.common.request,
)
.await?;
let releases = releases
.into_iter()
.filter(|r| bump_is_greater(¤t_version, &r.version).unwrap_or(false))
.collect();
Ok(Releases::new(releases, current_version))
}
async fn get_release_version_async(&self, ver: &str) -> Result<Release> {
use crate::backends::send_async;
let api_url = format!("{}/tags/{}", self.releases_url(), urlencoding::encode(ver));
let resp = send_async(
&api_url,
api_headers(self.common.auth_token.as_deref())?,
&self.common.request,
)
.await?;
let json = resp.json::<serde_json::Value>().await?;
Release::from_release_gitea(&json)
}
}
fn api_headers(auth_token: Option<&str>) -> Result<header::HeaderMap> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
"rust-reqwest/self-update"
.parse()
.expect("gitea invalid user-agent"),
);
if let Some(token) = auth_token {
headers.insert(
header::AUTHORIZATION,
format!("token {}", token)
.parse()
.map_err(|err| Error::Config(format!("Failed to parse auth token: {}", err)))?,
);
};
Ok(headers)
}
#[cfg(test)]
mod tests {
use super::Update;
use std::io::{Read, Write};
use std::net::TcpListener;
struct Resp {
status: &'static str,
link: Option<String>,
body: String,
}
fn stub(make: impl FnOnce(&str) -> Vec<Resp>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let responses = make(&base);
std::thread::spawn(move || {
for r in responses {
let (mut stream, _) = match listener.accept() {
Ok(c) => c,
Err(_) => return,
};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf); let mut out = format!(
"HTTP/1.1 {}\r\nContent-Type: application/json\r\n",
r.status
);
if let Some(link) = r.link {
out.push_str(&format!("Link: <{link}>; rel=\"next\"\r\n"));
}
out.push_str(&format!(
"Content-Length: {}\r\nConnection: close\r\n\r\n{}",
r.body.len(),
r.body
));
let _ = stream.write_all(out.as_bytes());
let _ = stream.flush();
}
});
base
}
fn release_json(tag: &str) -> String {
format!(
r#"[{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[],"body":null}}]"#
)
}
fn releases_json(tags: &[&str]) -> String {
let objs = tags
.iter()
.map(|tag| {
format!(
r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[],"body":null}}"#
)
})
.collect::<Vec<_>>()
.join(",");
format!("[{objs}]")
}
#[cfg(feature = "async")]
fn release_obj_json(tag: &str) -> String {
format!(
r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[],"body":null}}"#
)
}
#[cfg(feature = "async")]
fn gitea_update(base: &str, current_version: &str) -> Update {
Update::configure()
.url(base)
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version(current_version)
.build_async()
.unwrap()
}
fn gitea_update_sync(
base: &str,
current_version: &str,
) -> Box<dyn crate::update::ReleaseUpdate> {
Update::configure()
.url(base)
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version(current_version)
.build()
.unwrap()
}
#[test]
fn get_latest_release_sync_wraps_newest_into_one_element_releases() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: release_json("v2.5.0"),
}]
});
let upd = gitea_update_sync(&base, "1.0.0");
let releases = upd.get_latest_release().unwrap();
assert_eq!(
releases.all().len(),
1,
"get_latest_release yields a one-element Releases"
);
assert_eq!(releases.latest().unwrap().version, "2.5.0");
assert!(
releases.is_update_available().unwrap(),
"2.5.0 > 1.0.0 via the one-element Releases pre-check"
);
}
#[test]
fn get_latest_releases_sync_returns_releases_and_filters_to_newer() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: releases_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]),
}]
});
let upd = gitea_update_sync(&base, "1.0.0");
let releases = upd.get_latest_releases().unwrap();
let versions: Vec<&str> = releases.all().iter().map(|r| r.version.as_str()).collect();
assert_eq!(
versions,
vec!["2.0.0", "1.5.0"],
"only releases strictly newer than the current version are kept, in order"
);
assert_eq!(releases.latest().unwrap().version, "2.0.0");
assert!(
releases.is_update_available().unwrap(),
"the list path reports an update available when something newer exists"
);
}
#[test]
fn get_latest_releases_sync_reports_no_update_when_up_to_date() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: releases_json(&["v1.0.0", "v0.9.0"]),
}]
});
let upd = gitea_update_sync(&base, "1.0.0");
let releases = upd.get_latest_releases().unwrap();
assert!(releases.all().is_empty(), "no newer release => empty list");
assert!(
!releases.is_update_available().unwrap(),
"empty list => no update available"
);
}
#[test]
fn get_latest_release_sync_agrees_with_list_path_when_newest_equals_current() {
let make_body = || {
releases_json(&["v1.0.0", "v0.9.0"])
};
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: make_body(),
}]
});
let upd = gitea_update_sync(&base, "1.0.0");
let single = upd.get_latest_release().unwrap();
assert_eq!(single.latest().unwrap().version, "1.0.0");
assert!(
!single.is_update_available().unwrap(),
"get_latest_release: newest (1.0.0) == current => not available"
);
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: make_body(),
}]
});
let upd = gitea_update_sync(&base, "1.0.0");
let list = upd.get_latest_releases().unwrap();
assert!(
list.all().is_empty(),
"get_latest_releases: nothing strictly newer => filtered list is empty"
);
assert!(
list.latest().is_none(),
"get_latest_releases: empty filtered list => latest() is None"
);
assert!(
!list.is_update_available().unwrap(),
"get_latest_releases: nothing strictly newer => not available (agrees with single path)"
);
}
#[test]
fn url_and_filter_target_setters_exist_on_release_list_builder() {
let _list = super::ReleaseList::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.filter_target("x86_64-unknown-linux-gnu")
.build()
.unwrap();
}
#[test]
fn release_list_build_requires_url() {
let res = super::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.build();
assert!(matches!(res, Err(crate::errors::Error::Config(_))));
}
#[test]
fn api_headers_override_uses_gitea_user_agent_and_token_scheme() {
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.build()
.unwrap();
let headers = upd.api_headers(Some("secret")).unwrap();
assert_eq!(
headers
.get(crate::http_client::header::USER_AGENT)
.unwrap()
.to_str()
.unwrap(),
"rust-reqwest/self-update"
);
assert_eq!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.unwrap()
.to_str()
.unwrap(),
"token secret",
"gitea authenticates with the token scheme"
);
}
#[test]
fn release_list_build_surfaces_invalid_header() {
let res = super::ReleaseList::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.request_header("inva lid", "ok")
.build();
assert!(
matches!(res, Err(crate::errors::Error::Config(_))),
"invalid header must surface as Error::Config from gitea ReleaseList build()"
);
}
#[test]
fn update_build_surfaces_invalid_header() {
let res = Update::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.request_header("inva lid", "ok")
.build();
assert!(matches!(res, Err(crate::errors::Error::Config(_))));
}
#[test]
fn build_requires_url() {
let res = Update::configure()
.repo_owner("owner")
.repo_name("repo")
.bin_name("app")
.current_version("0.1.0")
.build();
assert!(res.is_err(), "build must fail without a host url");
}
#[test]
fn build_requires_repo_owner_and_name() {
let missing_owner = Update::configure()
.url("https://gitea.example.com")
.repo_name("repo")
.current_version("0.1.0")
.build();
assert!(missing_owner.is_err(), "build must fail without repo_owner");
let missing_name = Update::configure()
.url("https://gitea.example.com")
.repo_owner("owner")
.current_version("0.1.0")
.build();
assert!(missing_name.is_err(), "build must fail without repo_name");
}
#[test]
fn releases_url_is_correct() {
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("owner")
.repo_name("repo")
.bin_name("app")
.current_version("0.1.0")
.build_update()
.unwrap();
assert_eq!(
upd.releases_url(),
"https://gitea.example.com/api/v1/repos/owner/repo/releases"
);
}
#[test]
fn identifier_is_wired() {
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("owner")
.repo_name("repo")
.bin_name("app")
.current_version("0.1.0")
.asset_identifier("musl")
.build()
.unwrap();
assert_eq!(upd.asset_identifier(), Some("musl"));
}
#[test]
fn bin_name_sets_bin_path_in_archive_only_when_unset() {
let expected = format!("app{}", std::env::consts::EXE_SUFFIX);
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.build()
.unwrap();
assert_eq!(upd.bin_path_in_archive(), expected);
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.bin_path_in_archive("custom/path")
.bin_name("app")
.current_version("0.1.0")
.build()
.unwrap();
assert_eq!(upd.bin_path_in_archive(), "custom/path");
}
#[test]
fn bin_name_rederives_archive_path_on_second_call() {
let expected_b = format!("b{}", std::env::consts::EXE_SUFFIX);
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.bin_name("a")
.bin_name("b")
.current_version("0.1.0")
.build()
.unwrap();
assert_eq!(
upd.bin_path_in_archive(),
expected_b,
"second bin_name call must re-derive the archive path from the new name"
);
assert_eq!(
upd.bin_name(),
expected_b,
"bin_name() must reflect the second call"
);
}
#[test]
fn explicit_bin_path_survives_subsequent_bin_name_call() {
let upd = Update::configure()
.url("https://gitea.example.com")
.repo_owner("o")
.repo_name("r")
.bin_path_in_archive("x")
.bin_name("b")
.current_version("0.1.0")
.build()
.unwrap();
assert_eq!(
upd.bin_path_in_archive(),
"x",
"an explicit bin_path_in_archive must not be overwritten by a later bin_name call"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_latest_release_async_parses_release() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: release_json("v2.5.0"),
}]
});
let upd = Update::configure()
.url(&base)
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.build_async()
.unwrap();
let releases = upd.get_latest_release_async().await.unwrap();
assert_eq!(releases.latest().unwrap().version, "2.5.0");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn fetch_all_releases_async_follows_link_pagination() {
let base = stub(|base| {
vec![
Resp {
status: "200 OK",
link: Some(format!("{base}/api/v1/repos/o/r/releases?page=2")),
body: release_json("v1.0.0"),
},
Resp {
status: "200 OK",
link: None,
body: release_json("v0.9.0"),
},
]
});
let releases = super::fetch_all_releases_async(
&format!("{base}/api/v1/repos/o/r/releases"),
None,
&crate::backends::common::RequestConfig::default(),
)
.await
.unwrap();
assert_eq!(
releases.len(),
2,
"both pages accumulated over async transport"
);
assert_eq!(releases[0].version, "1.0.0");
assert_eq!(releases[1].version, "0.9.0");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_release_version_async_parses_single_tag_object() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: release_obj_json("v4.2.1"),
}]
});
let upd = gitea_update(&base, "0.1.0");
let rel = upd.get_release_version_async("v4.2.1").await.unwrap();
assert_eq!(rel.version, "4.2.1");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_release_version_async_errors_on_missing_tag_name() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: r#"{"created_at":"2020-01-01T00:00:00Z","assets":[]}"#.to_string(),
}]
});
let upd = gitea_update(&base, "0.1.0");
let res = upd.get_release_version_async("v1.0.0").await;
assert!(
matches!(res, Err(crate::errors::Error::Release(_))),
"missing tag_name must surface as Error::Release, got {:?}",
res
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_latest_releases_async_filters_to_newer_only() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: releases_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]),
}]
});
let upd = gitea_update(&base, "1.0.0");
let releases = upd.get_latest_releases_async().await.unwrap();
let versions: Vec<&str> = releases.all().iter().map(|r| r.version.as_str()).collect();
assert_eq!(
versions,
vec!["2.0.0", "1.5.0"],
"only releases strictly newer than the current version are kept, in order"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_latest_releases_async_empty_when_all_older_or_equal() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: releases_json(&["v1.0.0", "v0.9.0"]),
}]
});
let upd = gitea_update(&base, "1.0.0");
let releases = upd.get_latest_releases_async().await.unwrap();
assert!(
releases.all().is_empty(),
"no release newer than current => empty list, got {:?}",
releases.all()
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_latest_releases_async_accumulates_across_pages_then_filters() {
let base = stub(|base| {
vec![
Resp {
status: "200 OK",
link: Some(format!("{base}/api/v1/repos/o/r/releases?page=2")),
body: releases_json(&["v0.5.0"]),
},
Resp {
status: "200 OK",
link: None,
body: releases_json(&["v3.0.0"]),
},
]
});
let upd = gitea_update(&base, "1.0.0");
let releases = upd.get_latest_releases_async().await.unwrap();
let versions: Vec<&str> = releases.all().iter().map(|r| r.version.as_str()).collect();
assert_eq!(
versions,
vec!["3.0.0"],
"the newer release on page 2 survives pagination + filtering"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_latest_release_async_errors_on_empty_array() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: "[]".to_string(),
}]
});
let upd = gitea_update(&base, "0.1.0");
let res = upd.get_latest_release_async().await;
assert!(
matches!(res, Err(crate::errors::Error::Release(_))),
"empty releases array must surface as Error::Release, got {:?}",
res
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn get_latest_release_async_errors_on_non_array_payload() {
let base = stub(|_| {
vec![Resp {
status: "200 OK",
link: None,
body: "{}".to_string(),
}]
});
let upd = gitea_update(&base, "0.1.0");
let res = upd.get_latest_release_async().await;
assert!(
matches!(res, Err(crate::errors::Error::Release(_))),
"non-array payload must surface as Error::Release, got {:?}",
res
);
}
}