#![cfg(feature = "github")]
#![allow(missing_docs)]
use std::time::Duration;
use rtb_vcs::config::{GithubParams, ReleaseSourceConfig};
use rtb_vcs::github;
use rtb_vcs::release::{ProviderError, ReleaseProvider};
use secrecy::SecretString;
use tokio::io::AsyncReadExt as _;
use wiremock::matchers::{header, header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn provider(server: &MockServer, token: Option<&str>) -> std::sync::Arc<dyn ReleaseProvider> {
let host = server.uri().trim_start_matches("http://").trim_end_matches('/').to_string();
let cfg = ReleaseSourceConfig::Github(GithubParams {
host,
owner: "acme".into(),
repo: "widget".into(),
private: false,
timeout_seconds: 5,
allow_insecure_base_url: true,
});
let tok = token.map(|t| SecretString::from(t.to_string()));
github::factory(&cfg, tok).expect("factory")
}
fn sample_release_json(tag: &str, download_url: &str) -> serde_json::Value {
serde_json::json!({
"id": 42,
"name": format!("Release {tag}"),
"tag_name": tag,
"body": "release notes",
"draft": false,
"prerelease": false,
"created_at": "2026-04-23T10:00:00Z",
"published_at": "2026-04-23T10:05:00Z",
"assets": [{
"id": 100,
"name": "widget-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
"size": 1234,
"content_type": "application/gzip",
"browser_download_url": download_url,
}],
})
}
#[tokio::test(flavor = "current_thread")]
async fn t10_latest_release() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/latest"))
.and(header("Accept", "application/vnd.github+json"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(sample_release_json("v0.1.0", "https://example.invalid/assets/100")),
)
.mount(&server)
.await;
let p = provider(&server, None);
let r = p.latest_release().await.expect("latest");
assert_eq!(r.tag, "v0.1.0");
assert_eq!(r.name, "Release v0.1.0");
assert_eq!(r.body, "release notes");
assert_eq!(r.assets.len(), 1);
assert_eq!(r.assets[0].name, "widget-0.1.0-x86_64-unknown-linux-gnu.tar.gz");
assert_eq!(r.assets[0].size, 1234);
}
#[tokio::test(flavor = "current_thread")]
async fn t11_release_by_tag_encodes_slashes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/tags/release%2Fv0.1.0"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_release_json(
"release/v0.1.0",
"https://example.invalid/assets/100",
)))
.mount(&server)
.await;
let p = provider(&server, None);
let r = p.release_by_tag("release/v0.1.0").await.expect("by_tag");
assert_eq!(r.tag, "release/v0.1.0");
}
#[tokio::test(flavor = "current_thread")]
async fn t12_list_releases_caps_per_page_and_respects_limit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases"))
.and(query_param("per_page", "3"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
sample_release_json("v0.3.0", "https://example.invalid/a/3"),
sample_release_json("v0.2.0", "https://example.invalid/a/2"),
sample_release_json("v0.1.0", "https://example.invalid/a/1"),
sample_release_json("v0.0.1", "https://example.invalid/a/0"),
])))
.mount(&server)
.await;
let p = provider(&server, None);
let list = p.list_releases(3).await.expect("list");
assert_eq!(list.len(), 3, "expected truncation to limit");
assert_eq!(list[0].tag, "v0.3.0");
}
#[tokio::test(flavor = "current_thread")]
async fn t13_download_asset_streams_bytes() {
let server = MockServer::start().await;
let body = b"BINARY_PAYLOAD_BYTES_v0.1.0";
Mock::given(method("GET"))
.and(path("/download/widget.tar.gz"))
.and(header("Accept", "application/octet-stream"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec()))
.mount(&server)
.await;
let p = provider(&server, None);
let asset = rtb_vcs::release::ReleaseAsset::new(
"100",
"widget.tar.gz",
format!("{}/download/widget.tar.gz", server.uri()),
);
let (mut reader, _len) = p.download_asset(&asset).await.expect("download");
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await.expect("read");
assert_eq!(bytes, body);
}
#[tokio::test(flavor = "current_thread")]
async fn t14_unauthorized_maps_to_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/latest"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let p = provider(&server, Some("bad-token"));
let err = p.latest_release().await.expect_err("should 401");
match err {
ProviderError::Unauthorized { host } => {
assert!(host.starts_with("127.0.0.1"), "host: {host}");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn t15_rate_limited_with_retry_after_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/latest"))
.respond_with(
ResponseTemplate::new(403)
.insert_header("X-RateLimit-Remaining", "0")
.insert_header("Retry-After", "42"),
)
.mount(&server)
.await;
let p = provider(&server, None);
let err = p.latest_release().await.expect_err("should rate-limit");
match err {
ProviderError::RateLimited { host, retry_after } => {
assert!(host.starts_with("127.0.0.1"));
assert_eq!(retry_after, Some(Duration::from_secs(42)));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
fn assert_invalid_config(result: Result<std::sync::Arc<dyn ReleaseProvider>, ProviderError>) {
match result {
Ok(_) => panic!("expected InvalidConfig, got Ok"),
Err(ProviderError::InvalidConfig(_)) => {}
Err(other) => panic!("expected InvalidConfig, got {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn factory_rejects_http_host() {
let cfg = ReleaseSourceConfig::Github(GithubParams {
host: "http://github.example.com".into(),
owner: "o".into(),
repo: "r".into(),
private: false,
timeout_seconds: 30,
allow_insecure_base_url: false,
});
assert_invalid_config(github::factory(&cfg, None));
}
#[tokio::test(flavor = "current_thread")]
async fn factory_rejects_non_github_config() {
let cfg = ReleaseSourceConfig::Custom {
source_type: "whatever".into(),
params: std::collections::BTreeMap::new(),
};
assert_invalid_config(github::factory(&cfg, None));
}
#[tokio::test(flavor = "current_thread")]
async fn factory_rejects_empty_owner_or_repo() {
for (host, owner, repo) in
[("api.github.com", "", "r"), ("api.github.com", "o", ""), (" ", "o", "r")]
{
let cfg = ReleaseSourceConfig::Github(GithubParams {
host: host.into(),
owner: owner.into(),
repo: repo.into(),
private: false,
timeout_seconds: 30,
allow_insecure_base_url: false,
});
assert_invalid_config(github::factory(&cfg, None));
}
}
#[tokio::test(flavor = "current_thread")]
async fn authenticated_requests_send_bearer_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/latest"))
.and(header_exists("authorization"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(sample_release_json("v0.1.0", "https://example.invalid/assets/100")),
)
.mount(&server)
.await;
let p = provider(&server, Some("ghp_testsecret"));
let r = p.latest_release().await.expect("latest");
assert_eq!(r.tag, "v0.1.0");
}
#[tokio::test(flavor = "current_thread")]
async fn unauthenticated_requests_omit_authorization() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/latest"))
.and(header("user-agent", concat!("rtb-vcs/", env!("CARGO_PKG_VERSION"))))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(sample_release_json("v0.1.0", "https://example.invalid/assets/100")),
)
.mount(&server)
.await;
let p = provider(&server, None);
let r = p.latest_release().await.expect("latest");
assert_eq!(r.tag, "v0.1.0");
}
#[tokio::test(flavor = "current_thread")]
async fn not_found_maps_correctly() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widget/releases/tags/ghost"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let p = provider(&server, None);
let err = p.release_by_tag("ghost").await.expect_err("should 404");
assert!(matches!(err, ProviderError::NotFound { .. }));
}