syncweb-core 0.1.1-alpha.7

delay-tolerant web surfing
Documentation
use std::path::{Path, PathBuf};

use anyhow::Context;
use syncweb_core::{
    folder::{CollectionEntry, CollectionManifest, PackageManager},
    storage::node_db::NodeDatabase,
};

use crate::test_utils::TestDirectory;

fn make_source(dir: &Path, name: &str, data: &[u8]) -> anyhow::Result<PathBuf> {
    let path = dir.join(name);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, data)?;
    Ok(path)
}

fn make_manifest(
    collection_id: uuid::Uuid,
    version: &str,
    files: &[(&str, &[u8])],
) -> anyhow::Result<CollectionManifest> {
    let mut manifest = CollectionManifest::new(collection_id, version);
    for (name, data) in files {
        manifest.entries.push(CollectionEntry::new(
            iroh_blobs::Hash::new(data),
            PathBuf::from(name),
            u64::try_from(data.len())?,
        )?);
    }
    Ok(manifest)
}

fn write_source_files(base: &Path, files: &[(&str, &[u8])]) -> anyhow::Result<PathBuf> {
    let source = base.join(format!("source-{}", uuid::Uuid::new_v4()));
    for (name, data) in files {
        make_source(&source, name, data)?;
    }
    Ok(source)
}

/// 5.3 Integration Test: Full package lifecycle
/// init -> add -> bump -> publish -> search -> install -> upgrade -> remove
#[tokio::test]
async fn test_package_lifecycle() -> anyhow::Result<()> {
    let directory = TestDirectory::new("syncweb-package-test")?;
    let collection_id = uuid::Uuid::new_v4();
    let node_db = NodeDatabase::open(directory.path().join("node.db"))?;
    let packages = PackageManager::new(directory.path().join("packages"), node_db);

    // Create v1 manifest and source
    let v1_tool: &[u8] = b"tool-v1";
    let v1_readme: &[u8] = b"readme v1";
    let v1_files = &[("bin/tool", v1_tool), ("README.md", v1_readme)];
    let v1 = make_manifest(collection_id, "1.0.0", v1_files)?;
    let source_v1 = write_source_files(directory.path(), v1_files)?;

    // Install v1
    packages.install(&v1, &source_v1)?;

    // Verify v1 state
    let v1_state = packages.state()?;
    let v1_installed = v1_state.current(collection_id).context("v1 should be installed")?;
    anyhow::ensure!(v1_installed.current == "1.0.0");
    anyhow::ensure!(v1_installed.versions.contains_key("1.0.0"));

    // Upgrade: create v2 with different content
    let v2_tool: &[u8] = b"tool-v2";
    let v2_readme: &[u8] = b"readme v2";
    let v2_files = &[("bin/tool", v2_tool), ("README.md", v2_readme)];
    let v2 = make_manifest(collection_id, "2.0.0", v2_files)?;
    let source_v2 = write_source_files(directory.path(), v2_files)?;

    // Install v2 (upgrade)
    packages.install(&v2, &source_v2)?;

    // Verify both versions coexist
    let v2_state = packages.state()?;
    let v2_installed = v2_state.current(collection_id).context("v2 should be current")?;
    anyhow::ensure!(v2_installed.current == "2.0.0");
    anyhow::ensure!(v2_installed.versions.contains_key("1.0.0"));
    anyhow::ensure!(v2_installed.versions.contains_key("2.0.0"));

    // Switch back to v1
    packages.switch(collection_id, "1.0.0")?;
    let current_path = packages.root().join(collection_id.to_string()).join("current/bin/tool");
    anyhow::ensure!(std::fs::read(current_path)? == b"tool-v1");

    // Verify both versions
    packages.verify(&v1)?;
    packages.verify(&v2)?;

    // Remove v1 (must switch away first)
    packages.switch(collection_id, "2.0.0")?;
    packages.remove(collection_id, "1.0.0")?;

    // Verify v1 is gone, v2 remains
    let final_state = packages.state()?;
    let final_installed = final_state
        .current(collection_id)
        .context("collection should still exist")?;
    anyhow::ensure!(!final_installed.versions.contains_key("1.0.0"));
    anyhow::ensure!(final_installed.versions.contains_key("2.0.0"));
    anyhow::ensure!(final_installed.current == "2.0.0");

    // Verify v2 is still healthy after removing v1
    packages.verify(&v2)?;

    Ok(())
}

/// 5.3 Integration Test: Install v1, install v2, switch between
#[test]
fn test_multi_version_coexistence() -> anyhow::Result<()> {
    let directory = TestDirectory::new("syncweb-package-test")?;
    let collection_id = uuid::Uuid::new_v4();
    let node_db = NodeDatabase::open(directory.path().join("node.db"))?;
    let packages = PackageManager::new(directory.path().join("packages"), node_db);

    // Install v1
    let v1_content: &[u8] = b"content-v1";
    let v1_files = &[("data/file.txt", v1_content)];
    let v1 = make_manifest(collection_id, "1.0.0", v1_files)?;
    let source_v1 = write_source_files(directory.path(), v1_files)?;
    packages.install(&v1, &source_v1)?;

    // Install v2
    let v2_content: &[u8] = b"content-v2";
    let v2_files = &[("data/file.txt", v2_content)];
    let v2 = make_manifest(collection_id, "2.0.0", v2_files)?;
    let source_v2 = write_source_files(directory.path(), v2_files)?;
    packages.install(&v2, &source_v2)?;

    // Both version directories should exist
    let collection_dir = packages.root().join(collection_id.to_string());
    anyhow::ensure!(collection_dir.join("1.0.0").join("data/file.txt").exists());
    anyhow::ensure!(collection_dir.join("2.0.0").join("data/file.txt").exists());

    // current symlink should point to v2 (latest installed)
    let initial_link = std::fs::read_link(collection_dir.join("current"))?;
    anyhow::ensure!(initial_link == Path::new("2.0.0"));

    // Switch to v1
    packages.switch(collection_id, "1.0.0")?;
    let v1_link = std::fs::read_link(collection_dir.join("current"))?;
    anyhow::ensure!(v1_link == Path::new("1.0.0"));
    anyhow::ensure!(std::fs::read(collection_dir.join("current/data/file.txt"))? == b"content-v1");

    // Switch to v2
    packages.switch(collection_id, "2.0.0")?;
    let v2_link = std::fs::read_link(collection_dir.join("current"))?;
    anyhow::ensure!(v2_link == Path::new("2.0.0"));
    anyhow::ensure!(std::fs::read(collection_dir.join("current/data/file.txt"))? == b"content-v2");

    // State file tracks both versions
    let state = packages.state()?;
    let installed = state.current(collection_id).context("should have collection")?;
    anyhow::ensure!(installed.versions.len() == 2);
    anyhow::ensure!(installed.current == "2.0.0");

    Ok(())
}

/// 5.3 Integration Test: Stage -> verify -> symlink swap -> cleanup
#[test]
fn test_atomic_upgrade() -> anyhow::Result<()> {
    let directory = TestDirectory::new("syncweb-package-test")?;
    let collection_id = uuid::Uuid::new_v4();
    let node_db = NodeDatabase::open(directory.path().join("node.db"))?;
    let packages = PackageManager::new(directory.path().join("packages"), node_db);

    // Install v1
    let v1_data: &[u8] = b"v1-binary";
    let v1_files = &[("app.bin", v1_data)];
    let v1 = make_manifest(collection_id, "1.0.0", v1_files)?;
    let source_v1 = write_source_files(directory.path(), v1_files)?;
    packages.install(&v1, &source_v1)?;

    // Record v1 state
    let collection_dir = packages.root().join(collection_id.to_string());
    anyhow::ensure!(std::fs::read(collection_dir.join("current/app.bin"))? == b"v1-binary");
    anyhow::ensure!(std::fs::read_link(collection_dir.join("current"))? == Path::new("1.0.0"));

    // Install v2
    let v2_data: &[u8] = b"v2-binary-upgraded";
    let v2_files = &[("app.bin", v2_data)];
    let v2 = make_manifest(collection_id, "2.0.0", v2_files)?;
    let source_v2 = write_source_files(directory.path(), v2_files)?;
    packages.install(&v2, &source_v2)?;

    // Verify atomic swap: current now points to v2
    anyhow::ensure!(std::fs::read_link(collection_dir.join("current"))? == Path::new("2.0.0"));
    anyhow::ensure!(std::fs::read(collection_dir.join("current/app.bin"))? == b"v2-binary-upgraded");

    // Verify no staging artifacts remain
    let staging_artifacts: Vec<_> = std::fs::read_dir(&collection_dir)?
        .filter_map(std::result::Result::ok)
        .filter(|entry| {
            entry
                .file_name()
                .to_str()
                .is_some_and(|name| name.starts_with(".stage-") || name.starts_with(".current-"))
        })
        .collect();
    anyhow::ensure!(
        staging_artifacts.is_empty(),
        "staging artifacts should be cleaned up: {staging_artifacts:?}"
    );

    // Verify state is consistent
    let state = packages.state()?;
    let installed = state.current(collection_id).context("should exist")?;
    anyhow::ensure!(installed.current == "2.0.0");
    anyhow::ensure!(installed.versions.len() == 2);

    // Verify v1 is still independently accessible
    anyhow::ensure!(std::fs::read(collection_dir.join("1.0.0/app.bin"))? == b"v1-binary");

    Ok(())
}

/// 5.3 Integration Test: Verify catches corruption
#[test]
fn test_package_integrity() -> anyhow::Result<()> {
    let directory = TestDirectory::new("syncweb-package-test")?;
    let collection_id = uuid::Uuid::new_v4();
    let node_db = NodeDatabase::open(directory.path().join("node.db"))?;
    let packages = PackageManager::new(directory.path().join("packages"), node_db);

    // Install a package
    let original: &[u8] = b"original content";
    let files = &[("data.txt", original)];
    let manifest = make_manifest(collection_id, "1.0.0", files)?;
    let source = write_source_files(directory.path(), files)?;
    packages.install(&manifest, &source)?;

    // Verify passes before corruption
    packages.verify(&manifest)?;

    // Corrupt the installed file
    let file_path = packages.root().join(collection_id.to_string()).join("1.0.0/data.txt");
    std::fs::write(&file_path, b"corrupted content")?;

    // Verify should detect corruption
    let err = packages
        .verify(&manifest)
        .expect_err("verify should fail after corruption");
    anyhow::ensure!(
        format!("{err}").contains("does not match manifest"),
        "unexpected error: {err}"
    );

    // Verify a different manifest version not installed
    let wrong_manifest = make_manifest(collection_id, "9.9.9", files)?;
    anyhow::ensure!(packages.verify(&wrong_manifest).is_err());

    // Verify on a missing collection
    let missing_manifest = make_manifest(uuid::Uuid::new_v4(), "1.0.0", files)?;
    anyhow::ensure!(packages.verify(&missing_manifest).is_err());

    Ok(())
}