ic-query 0.27.1

Internet Computer query library for NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
use super::*;
use crate::{cache::CacheRecoveryPolicy, subnet_catalog::parse_utc_timestamp_secs};
use std::{fs, path::Path, time::SystemTime};

#[test]
fn status_separates_header_age_and_recovery_evidence_without_attempts() {
    let root = temp_dir("ic-query-cache-status");
    write_cache(
        &root.join("nns/ic/subnet-catalog/catalog.json"),
        r#"{"catalog_schema_version":1,"network":"ic","fetched_at":"2026-08-03T00:00:00Z"}"#,
    );
    write_cache(
        &root.join("nns/ic/governance/proposals/full.json"),
        r#"{"schema_version":1,"network":"ic","fetched_at":"2026-08-02T00:00:00Z"}"#,
    );
    write_cache(
        &root.join("sns/ic/root/proposals/full.refresh-attempt.json"),
        r#"{"schema_version":1}"#,
    );
    write_cache(
        &root.join("sns/ic/root/proposals/full.json"),
        r#"{"schema_version":1,"network":"ic","fetched_at":"not-a-timestamp","domain":"sns","entity":"root","collection":"proposals"}"#,
    );
    write_cache(
        &root.join("legacy/ic/full.json"),
        r#"{"schema_version":1,"network":"ic","fetched_at":"2026-08-03T00:00:00Z","domain":"sns","entity":"catalog","collection":"discovery"}"#,
    );
    write_cache(&root.join("nns/ic/node/nodes.json"), "not-json");

    let now = parse_utc_timestamp_secs("2026-08-04T00:00:00Z").expect("timestamp");
    let report =
        build_cache_status_report(&CacheStatusRequest::new(&root, now)).expect("cache status");

    assert_eq!(report.cache_count, 5);
    assert!(!report.family_validation_performed);
    assert_eq!(report.readable_header_count, 4);
    assert_eq!(report.invalid_header_count, 1);
    assert_eq!(report.fresh_count, 1);
    assert_eq!(report.unmanaged_age_count, 2);
    assert_eq!(report.unknown_age_count, 2);
    assert!(!report.truncated);
    let invalid_node = report
        .caches
        .iter()
        .find(|row| row.component == "nns/nodes")
        .expect("invalid node cache row");
    assert_eq!(invalid_node.header_status, CacheHeaderStatus::Invalid);
    assert_eq!(invalid_node.age_status, CacheAgeStatus::Unknown);
    assert_eq!(invalid_node.recovery_policy, CacheRecoveryPolicy::Automatic);
    let nns_proposals = report
        .caches
        .iter()
        .find(|row| row.component == "nns/governance/proposals")
        .expect("NNS proposal cache row");
    assert_eq!(nns_proposals.header_status, CacheHeaderStatus::Readable);
    assert_eq!(nns_proposals.age_status, CacheAgeStatus::Unmanaged);
    assert_eq!(nns_proposals.recovery_policy, CacheRecoveryPolicy::Explicit);
    let sns_proposals = report
        .caches
        .iter()
        .find(|row| row.relative_path == "sns/ic/root/proposals/full.json")
        .expect("SNS proposal cache row");
    assert_eq!(sns_proposals.header_status, CacheHeaderStatus::Readable);
    assert_eq!(sns_proposals.age_status, CacheAgeStatus::Unknown);
    assert_eq!(
        sns_proposals.recovery_policy,
        CacheRecoveryPolicy::MissingOnly
    );
    assert!(sns_proposals.inspection_error.is_some());
    let claimed_sns_catalog = report
        .caches
        .iter()
        .find(|row| row.relative_path == "legacy/ic/full.json")
        .expect("orphaned cache row");
    assert_eq!(
        claimed_sns_catalog.recovery_policy,
        CacheRecoveryPolicy::Unknown
    );
    assert_eq!(claimed_sns_catalog.age_status, CacheAgeStatus::Unmanaged);
    assert_eq!(claimed_sns_catalog.stale_after_seconds, None);
    let text = crate::cache::cache_status_report_text(&report);
    assert!(text.contains("family_validation_performed: no"));
    let header = text
        .lines()
        .find(|line| line.starts_with("HEADER"))
        .expect("cache status table header");
    assert!(header.contains("AGE STATE"));
    assert!(header.contains("RECOVERY"));
    let _ = fs::remove_dir_all(root);
}

#[test]
fn status_reports_active_stale_and_invalid_refresh_locks_without_removing_them() {
    let root = temp_dir("ic-query-refresh-lock-status");
    let now = parse_utc_timestamp_secs("2026-08-04T00:00:00Z").expect("timestamp");
    let active_lock = root.join("nns/ic/node/refresh.lock");
    let stale_lock = root.join("sns/ic/root/proposals/full.refresh.lock");
    let invalid_lock = root.join("nns/ic/subnet-topology/refresh.lock");
    let future_lock = root.join("nns/ic/node-provider/refresh.lock");
    write_refresh_lock(
        &active_lock,
        &root.join("nns/ic/node/nodes.json"),
        now.saturating_sub(30),
        60,
    );
    write_refresh_lock(
        &stale_lock,
        &root.join("sns/ic/root/proposals/full.json"),
        now.saturating_sub(61),
        60,
    );
    write_cache(&invalid_lock, "not-json");
    write_refresh_lock(
        &future_lock,
        &root.join("nns/ic/node-provider/providers.json"),
        now.saturating_add(1),
        60,
    );

    let report =
        build_cache_status_report(&CacheStatusRequest::new(&root, now)).expect("cache status");

    assert_eq!(report.cache_count, 0);
    assert_eq!(report.refresh_lock_count, 4);
    assert_eq!(report.active_refresh_lock_count, 1);
    assert_eq!(report.stale_refresh_lock_count, 1);
    assert_eq!(report.invalid_refresh_lock_count, 2);
    assert!(report.refresh_locks.iter().any(|row| {
        row.status == CacheRefreshLockStatus::Active && row.age_seconds == Some(30)
    }));
    assert!(report.refresh_locks.iter().any(|row| {
        row.status == CacheRefreshLockStatus::Stale
            && row.component == "sns/proposals"
            && row.age_seconds == Some(61)
    }));
    assert!(
        report
            .refresh_locks
            .iter()
            .any(|row| { row.status == CacheRefreshLockStatus::Invalid && row.error.is_some() })
    );
    let text = crate::cache::cache_status_report_text(&report);
    assert!(text.contains("REFRESH LOCKS"));
    assert!(text.contains("STALE AFTER"));
    assert!(active_lock.exists());
    assert!(stale_lock.exists());
    assert!(invalid_lock.exists());
    assert!(future_lock.exists());
    let _ = fs::remove_dir_all(root);
}

fn write_cache(path: &Path, contents: &str) {
    fs::create_dir_all(path.parent().expect("parent")).expect("create cache parent");
    fs::write(path, contents).expect("write cache");
}

fn write_refresh_lock(
    lock_path: &Path,
    target_path: &Path,
    started_at_unix_secs: u64,
    stale_after_seconds: u64,
) {
    let contents = serde_json::json!({
        "schema_version": 1,
        "network": "ic",
        "pid": 1234,
        "started_at_unix_ms": started_at_unix_secs.saturating_mul(1_000),
        "stale_after_seconds": stale_after_seconds,
        "target_path": target_path.display().to_string(),
    });
    write_cache(
        lock_path,
        &serde_json::to_string(&contents).expect("serialize refresh lock"),
    );
}

fn temp_dir(label: &str) -> std::path::PathBuf {
    let nonce = SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("clock")
        .as_nanos();
    std::env::temp_dir().join(format!("{label}-{nonce}"))
}