//! Integration test: drive the UI view-model ([`UiData`]) against a **real**
//! [`LocalHolger`] built from a real [`FastRoutes`] table — i.e. the actual
//! holger core routing, not a fake `HolgerObject`. This is the "UI talks to
//! holger core via direct rust calls" path the brief calls out, exercised
//! end-to-end (list → select/gate → put → fetch round-trip → error mapping).
//!
//! The repository backend is a tiny in-memory `RepositoryBackendTrait` impl so
//! the round-trip doesn't depend on any one ecosystem backend's on-disk format —
//! the point under test is the UI ↔ `LocalHolger` ↔ `FastRoutes` wiring.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use holger_server_ui::data::UiData;
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait};
/// Emit one functional-status row for a real check into the `nornir test` matrix.
/// Gated behind `--features testmatrix` so the release build strips it entirely.
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// In-memory writable/read-only repository backend for the round-trip.
struct MemRepo {
name: String,
writable: bool,
store: Mutex<HashMap<(Option<String>, String, String), Vec<u8>>>,
}
impl MemRepo {
fn new(name: &str, writable: bool) -> Self {
Self {
name: name.to_string(),
writable,
store: Mutex::new(HashMap::new()),
}
}
fn key(id: &ArtifactId) -> (Option<String>, String, String) {
(id.namespace.clone(), id.name.clone(), id.version.clone())
}
}
impl RepositoryBackendTrait for MemRepo {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rust
}
fn is_writable(&self) -> bool {
self.writable
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
Ok(self.store.lock().unwrap().get(&Self::key(id)).cloned())
}
fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
self.store.lock().unwrap().insert(Self::key(id), data.to_vec());
Ok(())
}
/// Enumerate the in-memory map into `ArtifactEntry`s — what a real
/// disk-backed repo's listing yields, but off a `HashMap`. The substring
/// `name_filter` matches against the artifact name; `limit == 0` means no
/// cap (the trait's "unlimited" convention).
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
let store = self.store.lock().unwrap();
let mut out = Vec::new();
for ((namespace, name, version), bytes) in store.iter() {
if let Some(f) = name_filter {
if !name.contains(f) {
continue;
}
}
out.push(traits::ArtifactEntry {
id: ArtifactId {
namespace: namespace.clone(),
name: name.clone(),
version: version.clone(),
},
size_bytes: bytes.len() as i64,
content_type: "application/octet-stream".into(),
});
if limit != 0 && out.len() >= limit {
break;
}
}
Ok(out)
}
/// Deterministic fake archive listing — a couple of fixed raw paths,
/// honouring the optional `prefix` filter. Stands in for a znippy backend's
/// `archive_files` so the Archive round-trip doesn't need a real archive.
fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
let files = vec![
"crates/serde-1.0.0.crate".to_string(),
"crates/tokio-1.2.0.crate".to_string(),
];
Ok(match prefix {
Some(p) => files.into_iter().filter(|f| f.starts_with(p)).collect(),
None => files,
})
}
/// Deterministic fake archive stats matching `archive_files` above.
fn archive_info(&self) -> anyhow::Result<traits::ArchiveInfo> {
Ok(traits::ArchiveInfo {
file_count: 2,
total_uncompressed_bytes: 1234,
archive_path: self.name.clone(),
})
}
fn handle_http2_request(
&self,
_method: &str,
_suburl: &str,
_body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, Vec::new(), b"not found".to_vec()))
}
}
fn local_ui() -> UiData {
let writable: Arc<dyn RepositoryBackendTrait> = Arc::new(MemRepo::new("rust-dev", true));
let readonly: Arc<dyn RepositoryBackendTrait> = Arc::new(MemRepo::new("rust-prod", false));
let routes = FastRoutes::new(vec![
("rust-dev".to_string(), writable),
("rust-prod".to_string(), readonly),
]);
let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(routes));
UiData::new(holger).expect("runtime")
}
#[test]
fn ui_data_round_trips_through_real_local_holger() {
let mut ui = local_ui();
// Health flows from LocalHolger::health (real core).
ui.refresh_status();
assert!(ui.status.loaded, "status: {:?}", ui.status.error);
assert!(!ui.status.version.is_empty());
// Repos come from FastRoutes.all_repos() via list_repositories.
ui.refresh_repos();
assert_eq!(ui.repos.repos.len(), 2);
let dev = ui
.repos
.repos
.iter()
.position(|r| r.name == "rust-dev")
.expect("rust-dev present");
let prod = ui
.repos
.repos
.iter()
.position(|r| r.name == "rust-prod")
.expect("rust-prod present");
// Upload gate tracks the selected repo's real `is_writable()`.
ui.select_repo(dev);
assert!(ui.repos.upload_enabled());
ui.select_repo(prod);
assert!(!ui.repos.upload_enabled());
// Put → fetch round-trip on the writable repo, through real LocalHolger.
let id = ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
ui.put_artifact("rust-dev", &id, b"CRATE_BYTES").expect("put ok");
ui.fetch_artifact("rust-dev", id.clone());
assert!(ui.artifact.found);
assert_eq!(ui.artifact.size_bytes, b"CRATE_BYTES".len() as u64);
assert_eq!(ui.artifact.data, b"CRATE_BYTES");
// Browse the writable repo through real LocalHolger: exercises the listing
// path UI → LocalHolger::list_artifacts → backend.list (no network). The
// artifact we just put must show up with the right id and byte size.
ui.refresh_browse("rust-dev", None);
assert!(ui.browse.error.is_none(), "browse error: {:?}", ui.browse.error);
let row = ui
.browse
.entries
.iter()
.find(|r| r.name == "serde" && r.version == "1.0.0")
.expect("put artifact present in browse listing");
assert_eq!(row.size_bytes, b"CRATE_BYTES".len() as i64);
#[cfg_attr(not(feature = "testmatrix"), allow(unused_variables))]
let browsed_size = row.size_bytes;
// Archive browse through real LocalHolger: exercises UI →
// LocalHolger::list_archive_files / archive_info → backend (no network). The
// MemRepo serves a deterministic fake archive.
ui.refresh_archive("rust-dev", None);
assert!(ui.archive.error.is_none(), "archive error: {:?}", ui.archive.error);
assert_eq!(ui.archive.repository, "rust-dev");
assert_eq!(ui.archive.file_count, 2);
assert_eq!(ui.archive.total_uncompressed_bytes, 1234);
assert_eq!(ui.archive.archive_path, "rust-dev");
assert_eq!(ui.archive.files.len(), 2);
assert!(ui
.archive
.files
.iter()
.any(|f| f == "crates/serde-1.0.0.crate"));
// LocalHolger enforces read-only on the real core path.
let err = ui.put_artifact("rust-prod", &id, b"x").unwrap_err();
assert!(
err.to_string().contains("read-only"),
"unexpected error: {err}"
);
// A genuine miss is rendered as not-found, not an error.
let missing = ArtifactId {
namespace: None,
name: "does-not-exist".into(),
version: "0.0.0".into(),
};
ui.fetch_artifact("rust-dev", missing);
assert!(!ui.artifact.found);
assert!(ui.artifact.error.is_none());
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"local_core_round_trip",
ui.repos.repos.len() == 2
&& browsed_size == b"CRATE_BYTES".len() as i64
&& ui.archive.file_count == 2
&& err.to_string().contains("read-only")
&& !ui.artifact.found,
&format!(
"LocalHolger: {} repos, put/fetch {} bytes, archive {} files, read-only enforced, miss=not-found",
ui.repos.repos.len(),
browsed_size,
ui.archive.file_count,
),
);
}
#[test]
fn browse_paginates_with_load_more() {
// Put more than one page (BROWSE_PAGE_SIZE = 100) of artifacts, then page
// through them via refresh_browse + load_more_browse. The paginator sorts
// into a stable total order, so the union across pages is exactly the set
// with no duplicates — even though MemRepo lists off an unordered HashMap.
let mut ui = local_ui();
let total = 150usize;
for i in 0..total {
let id = ArtifactId {
namespace: None,
name: format!("crate-{i:03}"),
version: "1.0.0".into(),
};
ui.put_artifact("rust-dev", &id, b"x").expect("put");
}
// First page: full page + a continuation token.
ui.refresh_browse("rust-dev", None);
assert!(ui.browse.error.is_none(), "browse error: {:?}", ui.browse.error);
assert_eq!(ui.browse.entries.len(), 100);
assert!(!ui.browse.next_page_token.is_empty(), "expected a next-page token");
// Load the remainder: appends the last 50, token clears.
ui.load_more_browse();
assert_eq!(ui.browse.entries.len(), total);
assert!(ui.browse.next_page_token.is_empty(), "listing should be exhausted");
// No duplicates and complete coverage across the two pages.
let mut names: Vec<&str> = ui.browse.entries.iter().map(|r| r.name.as_str()).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), total, "pages overlapped or missed entries");
#[cfg_attr(not(feature = "testmatrix"), allow(unused_variables))]
let unique_names = names.len();
// A further load_more is a no-op (token already empty).
ui.load_more_browse();
assert_eq!(ui.browse.entries.len(), total);
#[cfg(feature = "testmatrix")]
fstatus(
"holger-server-ui",
"browse_paginates_with_load_more",
ui.browse.entries.len() == total && unique_names == total,
&format!("paged {total} artifacts across 2 pages; {unique_names} unique, no dups/misses"),
);
}