- .github/workflows/release.yml: on tag v* build all 5 targets (macOS
arm64/x86_64, Linux arm64/x86_64 incl. free arm runners, Windows
x86_64) with the release-dist profile, archive kigi-<version>-<triple>
with LICENSE/NOTICE/THIRD-PARTY-NOTICES, generate SHA256SUMS, publish
the release (prerelease for tags containing '-'), with a tag↔workspace
version guard.
- install.sh / install.ps1 (repo root): platform detection, latest or
--version download from GitHub Releases, SHA-256 verification against
SHA256SUMS, install into the kigi home's downloads/ + bin/kigi symlink
(the same layout the self-updater manages), smoke test, PATH guidance.
- kigi-update rewritten onto the GitHub Releases API (documented wire
shape; stable=/latest, alpha=semver-max across the list, pinned=/tags):
SHA-256 gate before any binary swap, tar.gz/zip extraction per
platform, atomic bin/kigi symlink swap, channel/rollback semantics and
the KIGI_AUTO_UPDATE gate preserved verbatim; every x.ai/GCS/npm
endpoint deleted, npm/gh-release installers removed, legacy grok/agent
links retired on install. kigi-env owns the update base URL with a
KIGI_UPDATE_BASE_URL override (this is what the test artifact server
injects).
- .cargo/config.toml: removed the non-portable neoverse-v2 CPU pin on
Linux arm64 (fleet-specific); RELRO/NX hardening link-args now apply
to the gnu targets too, matching the release-dist profile's contract.
- THIRD-PARTY-NOTICES regenerated via cargo-about (about.toml +
template); the M0 hand-built file is dropped and README points at the
generated one. docs/RELEASE.md carries the release checklist.
- Deleted xAI-era leftovers: kigi-tui/scripts/install*.{sh,ps1} (x.ai
CDN) and the @xai-official/grok npm skeleton (PRD F8: no npm).
Gates: fmt clean; workspace check/clippy 0/0 (--locked, -D warnings);
kigi-update 58 lib + 86 integration tests green; deny ok;
release-dist build of kigi-bin succeeds and reports 'kigi 0.1.0'.
687 lines
25 KiB
Rust
687 lines
25 KiB
Rust
//! 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 intentionally 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("<html>not json</html>"))
|
|
.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<u8> = (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::<u8>::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<Vec<u8>>,
|
|
}
|
|
|
|
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::<usize>(), end_str.parse::<usize>())
|
|
{
|
|
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<u8> = (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"
|
|
);
|
|
}
|