holger-ui 0.1.1

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! 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_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-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-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"),
    );
}