//! Network-level integration tests using `wiremock`. //! //! Covers the HTTP-fetching paths in `version.rs` that take a URL parameter //! directly. We don't need `serial_test` here because each `MockServer` binds //! to its own random port and tests don't touch global state. //! //! Release JSON fixtures mirror the real GitHub REST API //! (https://docs.github.com/en/rest/releases/releases#get-the-latest-release): //! `GET /repos/{owner}/{repo}/releases/latest` → //! `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`. //! //! NOTE on retry timing: the prod retry backoff is 1s + 2s + 4s = 7s //! wall-clock. We can't use `tokio::time::pause()` because reqwest's I/O //! reactor uses the same tokio timer and stalls when time is paused. So //! retry-exhaustion tests are intrinsically slow (~7s each); we keep the //! count small and let them run in parallel (wiremock binds random ports //! so there's no contention). use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use kigi_update::auto_update::{download_silent, download_with_progress}; use kigi_update::version::{fetch_latest_release_from_base, fetch_release_for_version_from_base}; fn tag_json(tag: &str) -> serde_json::Value { serde_json::json!({ "tag_name": tag, "draft": false, "prerelease": false, "assets": [] }) } // Happy-path tests (fast, no retries triggered). #[tokio::test] async fn latest_release_returns_version_on_success() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181"))) .expect(1) .mount(&server) .await; let release = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.181"); } #[tokio::test] async fn latest_release_accepts_bare_semver_tag() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("0.1.181"))) .mount(&server) .await; let release = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.181"); } #[tokio::test] async fn latest_release_rejects_non_semver_tag_without_retry() { // A non-semver tag is a repo data bug, not a transient failure — the // fetch succeeds in one request and version() reports the bad tag. let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("release-one"))) .expect(1) .mount(&server) .await; let release = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap(); let err = release.version().unwrap_err(); assert!(format!("{err}").contains("not semver"), "err: {err}"); } #[tokio::test] async fn alpha_channel_picks_semver_max_from_release_list() { // The list is ordered by publication date (newest first) — NOT semver. // Alpha must take the semver max, so a newer-published pre-release does // not shadow a semver-higher stable and vice versa. let server = MockServer::start().await; let list = serde_json::json!([ { "tag_name": "v0.1.180-alpha.5", "draft": false, "prerelease": true, "assets": [] }, { "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] }, { "tag_name": "v0.1.179", "draft": false, "prerelease": false, "assets": [] }, ]); Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(list)) .expect(1) .mount(&server) .await; let release = fetch_latest_release_from_base("alpha", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.181"); } #[tokio::test] async fn alpha_channel_returns_prerelease_when_it_is_max() { let server = MockServer::start().await; let list = serde_json::json!([ { "tag_name": "v0.1.182-alpha.1", "draft": false, "prerelease": true, "assets": [] }, { "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] }, ]); Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(list)) .mount(&server) .await; let release = fetch_latest_release_from_base("alpha", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.182-alpha.1"); } #[tokio::test] async fn alpha_channel_skips_drafts_and_non_semver_tags() { let server = MockServer::start().await; let list = serde_json::json!([ { "tag_name": "v9.9.9", "draft": true, "prerelease": false, "assets": [] }, { "tag_name": "nightly", "draft": false, "prerelease": false, "assets": [] }, { "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] }, ]); Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(list)) .mount(&server) .await; let release = fetch_latest_release_from_base("alpha", &server.uri()) .await .unwrap(); assert_eq!( release.version().unwrap(), "0.1.181", "drafts and non-semver tags must not win" ); } #[tokio::test] async fn alpha_channel_empty_list_is_an_error() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) .mount(&server) .await; let err = fetch_latest_release_from_base("alpha", &server.uri()) .await .unwrap_err(); assert!(format!("{err:#}").contains("no releases"), "err: {err:#}"); } #[tokio::test] async fn stable_channel_does_not_fetch_the_release_list() { // Stable users resolve /latest only; the list endpoint must not be hit. let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181"))) .expect(1) .mount(&server) .await; Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(500)) .expect(0) .mount(&server) .await; let release = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.181"); } #[tokio::test] async fn release_for_version_fetches_tag_endpoint() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/tags/v0.1.150")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.150"))) .expect(1) .mount(&server) .await; let release = fetch_release_for_version_from_base("0.1.150", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.150"); } #[tokio::test] async fn base_url_trailing_slash_is_tolerated() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181"))) .mount(&server) .await; let base = format!("{}/", server.uri()); let release = fetch_latest_release_from_base("stable", &base) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.181"); } // Retry behavior — these tests deliberately exercise the 1s+2s+4s backoff, // so each takes up to ~7 seconds. They run in parallel. #[tokio::test] async fn latest_release_retries_on_5xx_then_succeeds() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(503).set_body_string("backend down")) .up_to_n_times(2) .mount(&server) .await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181"))) .mount(&server) .await; let release = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap(); assert_eq!(release.version().unwrap(), "0.1.181"); } #[tokio::test] async fn latest_release_gives_up_after_max_retries() { let server = MockServer::start().await; // 4 attempts total: initial + 3 retries. Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(500)) .expect(4) .mount(&server) .await; let err = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("HTTP 500"), "msg: {msg}"); assert!(msg.contains("/latest"), "url should be in error: {msg}"); } #[tokio::test] async fn latest_release_404_fails_fast_without_retry() { // 404 = release/repo missing — a data condition, not transient. Exactly // one request, and the GitHub error body is surfaced. let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not Found"}"#)) .expect(1) .mount(&server) .await; let err = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("HTTP 404"), "msg: {msg}"); assert!(msg.contains("Not Found"), "msg: {msg}"); } #[tokio::test] async fn latest_release_malformed_json_fails_without_retry() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/latest")) .respond_with(ResponseTemplate::new(200).set_body_string("not json")) .expect(1) .mount(&server) .await; let err = fetch_latest_release_from_base("stable", &server.uri()) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("unexpected JSON"), "msg: {msg}"); } #[tokio::test] async fn latest_release_connection_refused_is_retried_and_returns_error() { // Bind a TcpListener to claim a port, then drop it so connections refuse. let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); let url = format!("http://127.0.0.1:{port}"); let err = fetch_latest_release_from_base("stable", &url) .await .unwrap_err(); let msg = format!("{err:#}").to_lowercase(); assert!( msg.contains("request failed") || msg.contains("connection") || msg.contains("error sending request") || msg.contains("refused"), "expected network error message, got: {msg}" ); } // download_silent — same body shape as download_with_progress but no // progress bar to capture. #[tokio::test] async fn download_silent_writes_body_to_dest() { let server = MockServer::start().await; let body = b"binary contents \x00\x01\x02".to_vec(); Mock::given(method("GET")) .and(path("/kigi-0.1.181-macos-aarch64")) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); let url = format!("{}/kigi-0.1.181-macos-aarch64", server.uri()); download_silent(&url, &dest).await.unwrap(); let written = std::fs::read(&dest).unwrap(); assert_eq!(written, body); } #[tokio::test] async fn download_silent_preserves_binary_bytes_unchanged() { // Verify that arbitrary binary content (including null bytes, high // bytes, control chars) round-trips intact. let server = MockServer::start().await; let body: Vec = (0u8..=255).cycle().take(10_000).collect(); Mock::given(method("GET")) .and(path("/bin")) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("bin"); download_silent(&format!("{}/bin", server.uri()), &dest) .await .unwrap(); let written = std::fs::read(&dest).unwrap(); assert_eq!(written, body); } #[tokio::test] async fn download_silent_atomically_renames_via_tmp_file() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/bin")) .respond_with(ResponseTemplate::new(200).set_body_string("hello")) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); download_silent(&format!("{}/bin", server.uri()), &dest) .await .unwrap(); // After successful download, only the final file should exist. assert!(dest.exists()); assert!( !dest.with_extension("tmp").exists(), "tmp file must be renamed away on success" ); } /// A downloaded artifact must be published already executable (the install /// path execs it right after download). #[cfg(unix)] #[tokio::test] async fn download_silent_publishes_executable() { use std::os::unix::fs::PermissionsExt; let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/bin")) .respond_with(ResponseTemplate::new(200).set_body_string("#!/bin/sh\necho ok\n")) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi-0.1.181-linux-x86_64"); download_silent(&format!("{}/bin", server.uri()), &dest) .await .unwrap(); let mode = std::fs::metadata(&dest).unwrap().permissions().mode(); assert_ne!( mode & 0o111, 0, "downloaded artifact must be executable on publish (mode {mode:o})" ); } #[tokio::test] async fn download_silent_fails_on_4xx() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/missing")) .respond_with(ResponseTemplate::new(404).set_body_string("not found")) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); let err = download_silent(&format!("{}/missing", server.uri()), &dest) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("Download failed"), "msg: {msg}"); assert!(msg.contains("404"), "msg: {msg}"); assert!(!dest.exists(), "no file should be created on HTTP error"); } #[tokio::test] async fn download_silent_fails_on_5xx() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/x")) .respond_with(ResponseTemplate::new(503)) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); let err = download_silent(&format!("{}/x", server.uri()), &dest) .await .unwrap_err(); assert!(format!("{err:#}").contains("503")); } #[tokio::test] async fn download_silent_overwrites_existing_dest() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/x")) .respond_with(ResponseTemplate::new(200).set_body_string("new content")) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); std::fs::write(&dest, "old content").unwrap(); download_silent(&format!("{}/x", server.uri()), &dest) .await .unwrap(); let written = std::fs::read_to_string(&dest).unwrap(); assert_eq!(written, "new content"); } #[tokio::test] async fn download_silent_handles_empty_body() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/x")) .respond_with(ResponseTemplate::new(200).set_body_bytes(Vec::::new())) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); download_silent(&format!("{}/x", server.uri()), &dest) .await .unwrap(); assert!(dest.exists()); assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0); } #[tokio::test] async fn download_silent_streams_large_body() { // 5 MB to verify streaming (file is written incrementally, not loaded // entirely in memory before write). let server = MockServer::start().await; let body = vec![0xAB_u8; 5 * 1024 * 1024]; Mock::given(method("GET")) .and(path("/big")) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); download_silent(&format!("{}/big", server.uri()), &dest) .await .unwrap(); let written = std::fs::read(&dest).unwrap(); assert_eq!(written.len(), body.len()); assert_eq!(written, body); } #[tokio::test] async fn download_silent_to_nonexistent_parent_dir_fails() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/x")) .respond_with(ResponseTemplate::new(200).set_body_string("hi")) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); // Parent directory does NOT exist — should fail at file create. let dest = tmp.path().join("missing-subdir").join("kigi"); let err = download_silent(&format!("{}/x", server.uri()), &dest) .await .unwrap_err(); let msg = format!("{err:#}").to_lowercase(); assert!( msg.contains("no such file") || msg.contains("not found") || msg.contains("os error"), "expected fs error: {msg}" ); } // download_with_progress — same contract; covers the spinner path // (no Content-Length) and the progress-bar path (with Content-Length). #[tokio::test] async fn download_with_progress_writes_body_with_content_length() { // Wiremock sets Content-Length when set_body_bytes is used, so this // exercises the determinate-progress-bar path. let server = MockServer::start().await; let body = b"binary content".to_vec(); Mock::given(method("GET")) .and(path("/kigi")) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone())) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); download_with_progress(&format!("{}/kigi", server.uri()), &dest) .await .unwrap(); assert_eq!(std::fs::read(&dest).unwrap(), body); } #[tokio::test] async fn download_with_progress_fails_on_http_error() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/x")) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); let err = download_with_progress(&format!("{}/x", server.uri()), &dest) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("Download failed"), "msg: {msg}"); assert!(msg.contains("500"), "msg: {msg}"); } #[tokio::test] async fn download_with_progress_atomic_rename() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/x")) .respond_with(ResponseTemplate::new(200).set_body_string("ok")) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi"); download_with_progress(&format!("{}/x", server.uri()), &dest) .await .unwrap(); assert!(dest.exists()); assert!(!dest.with_extension("tmp").exists()); } // Parallel byte-range path — exercises the HEAD + 206 Partial Content code path // in download_silent / download_with_progress for files >= 16 MiB. /// Wiremock responder for `GET` that honors `Range: bytes=A-B` with `206`. /// Without a Range header it returns the full body with `200`. #[derive(Clone)] struct RangeResponder { body: std::sync::Arc>, } impl Respond for RangeResponder { fn respond(&self, request: &Request) -> ResponseTemplate { let total = self.body.len(); let spec = request .headers .get("range") .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("bytes=").map(|x| x.to_string())); if let Some(spec) = spec && let Some((start_str, end_str)) = spec.split_once('-') && let (Ok(start), Ok(end)) = (start_str.parse::(), end_str.parse::()) { let end = end.min(total - 1); if start <= end { let slice = self.body[start..=end].to_vec(); return ResponseTemplate::new(206) .insert_header("content-range", format!("bytes {start}-{end}/{total}")) .set_body_bytes(slice); } } ResponseTemplate::new(200).set_body_bytes((*self.body).clone()) } } #[tokio::test] async fn download_silent_parallel_path_reassembles_bytes() { // 32 MiB body — clears the parallel threshold and yields 2 chunks // (size_mb / 16 = 2, clamped to [1, 8]), so this actually exercises // concurrent range fetches and the seek+write reassembly. let body: Vec = (0u32..(32 * 1024 * 1024 / 4)) .flat_map(|n| n.to_le_bytes()) .collect(); assert_eq!(body.len(), 32 * 1024 * 1024); let arc = std::sync::Arc::new(body.clone()); let server = MockServer::start().await; Mock::given(method("HEAD")) .and(path("/big")) .respond_with( ResponseTemplate::new(200) .insert_header("content-length", body.len().to_string()) .insert_header("accept-ranges", "bytes"), ) .mount(&server) .await; Mock::given(method("GET")) .and(path("/big")) .respond_with(RangeResponder { body: arc }) .mount(&server) .await; let tmp = tempfile::tempdir().unwrap(); let dest = tmp.path().join("kigi-binary"); download_silent(&format!("{}/big", server.uri()), &dest) .await .unwrap(); let written = std::fs::read(&dest).unwrap(); assert_eq!(written.len(), body.len()); assert_eq!( written, body, "reassembled file must match original byte-for-byte" ); assert!( !dest.with_extension("tmp").exists(), "tmp file must be cleaned up" ); }