tandem-server 0.4.25

HTTP server for Tandem engine APIs
Documentation
use super::*;

#[tokio::test]
async fn marketplace_catalog_and_files_roundtrip() {
    let _guard = marketplace_test_lock();
    let state = test_state().await;
    let root = std::env::temp_dir().join(format!("tandem-marketplace-{}", Uuid::new_v4()));
    std::fs::create_dir_all(&root).expect("mkdir");
    let zip_path = root.join("seed-pack-1.0.0.zip");
    write_pack_zip_with_entries(
        &zip_path,
        r#"
manifest_schema_version: 1
pack_id: seed-pack
name: seed-pack
version: 1.0.0
type: workflow
engine:
  requires: ">=0.9.0 <2.0.0"
marketplace:
  publisher:
    publisher_id: pub_tandem_official
    display_name: Tandem
    verification_tier: official
  listing:
    display_name: Seed Pack
    description: Starter marketplace pack for testing.
    categories: ["planning"]
    tags: ["seed", "planning"]
    license_spdx: Apache-2.0
    icon: resources/marketplace/icon.svg
    screenshots: ["resources/marketplace/screenshot-1.svg"]
    changelog: CHANGELOG.md
entrypoints:
  workflows:
    - seed_workflow
contents:
  workflows:
    - id: seed_workflow
      path: workflows/seed_workflow.yaml
"#,
        &[
            ("README.md", "# Seed Pack\n\nA compact marketplace seed."),
            ("CHANGELOG.md", "## 1.0.0\n- Initial seed pack"),
            (
                "resources/marketplace/icon.svg",
                "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\"><rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#1d4ed8\"/></svg>",
            ),
            (
                "resources/marketplace/screenshot-1.svg",
                "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\"><rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#0f172a\"/></svg>",
            ),
            (
                "workflows/seed_workflow.yaml",
                "workflow:\n  id: seed_workflow\n  name: Seed Workflow\n  steps:\n    - action: agent:project-manager\n      with:\n        prompt: Hello from the marketplace seed pack\n",
            ),
        ],
    );
    let catalog_path = root.join("catalog.json");
    std::fs::write(
        &catalog_path,
        serde_json::to_string_pretty(&json!({
            "schema_version": "1",
            "generated_at": "2026-01-01T00:00:00Z",
            "packs": [
                {
                    "schema_version": "1",
                    "pack_id": "seed-pack",
                    "name": "seed-pack",
                    "version": "1.0.0",
                    "publisher": {
                        "publisher_id": "pub_tandem_official",
                        "display_name": "Tandem",
                        "verification_tier": "official",
                        "website": "https://tandem.ac/",
                        "support": "support@tandem.ac"
                    },
                    "listing": {
                        "display_name": "Seed Pack",
                        "description": "Starter marketplace pack for testing.",
                        "categories": ["planning"],
                        "tags": ["seed", "planning"],
                        "license_spdx": "Apache-2.0",
                        "icon_url": "resources/marketplace/icon.svg",
                        "screenshot_urls": ["resources/marketplace/screenshot-1.svg"],
                        "changelog_url": "CHANGELOG.md"
                    },
                    "distribution": {
                        "download_url": "seed-pack-1.0.0.zip",
                        "sha256": "abc",
                        "size_bytes": 123,
                        "signature_status": "missing"
                    },
                    "pack_source_dir": "private/seed-pack",
                    "workflow_ids": ["seed_workflow"],
                    "capabilities": {}
                }
            ]
        }))
        .expect("catalog json"),
    )
    .expect("write catalog");
    let previous = std::env::var("TANDEM_MARKETPLACE_CATALOG_PATH").ok();
    std::env::set_var("TANDEM_MARKETPLACE_CATALOG_PATH", &catalog_path);

    let app = app_router(state);
    let catalog_resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/marketplace/catalog")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
    assert_eq!(catalog_resp.status(), StatusCode::OK);
    let catalog_body = to_bytes(catalog_resp.into_body(), usize::MAX)
        .await
        .expect("catalog body");
    let catalog_json: Value = serde_json::from_slice(&catalog_body).expect("catalog json");
    assert_eq!(
        catalog_json
            .get("packs")
            .and_then(|v| v.as_array())
            .map(|rows| rows.len()),
        Some(1)
    );
    let zip_path_value = catalog_json["packs"][0]["distribution"]["zip_path"]
        .as_str()
        .unwrap_or("");
    assert!(zip_path_value.ends_with("seed-pack-1.0.0.zip"));

    let file_resp = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/marketplace/packs/seed-pack/files/README.md")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
    assert_eq!(file_resp.status(), StatusCode::OK);
    assert_eq!(
        file_resp
            .headers()
            .get("content-type")
            .and_then(|v| v.to_str().ok()),
        Some("text/markdown; charset=utf-8")
    );
    let file_body = to_bytes(file_resp.into_body(), usize::MAX)
        .await
        .expect("file body");
    let body_text = String::from_utf8(file_body.to_vec()).expect("utf8");
    assert!(body_text.contains("Seed Pack"));

    match previous {
        Some(value) => std::env::set_var("TANDEM_MARKETPLACE_CATALOG_PATH", value),
        None => std::env::remove_var("TANDEM_MARKETPLACE_CATALOG_PATH"),
    }
    let _ = std::fs::remove_dir_all(root);
}

fn marketplace_test_lock() -> std::sync::MutexGuard<'static, ()> {
    use std::sync::{Mutex, OnceLock};
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
        .lock()
        .expect("lock marketplace test")
}