//! End-to-end theme-store test: a real `solitaire_server` router //! serving a scanned catalog over a localhost TCP socket, consumed by //! [`solitaire_data::ThemeStoreClient`]. //! //! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0, //! router in a background `tokio::spawn`, no explicit shutdown. #![cfg(not(target_arch = "wasm32"))] use std::io::Write as _; use std::path::{Path, PathBuf}; use solitaire_data::ThemeStoreClient; use solitaire_server::theme_store::ThemeStore; /// Minimal theme archive: the catalog scan only reads the `meta` /// block, so no face SVGs are needed. fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf { let manifest = format!( r#"( meta: ( id: "{id}", name: "{name}", author: "Round Trip", version: "1.0.0", card_aspect: (2, 3), ), faces: {{}}, back: "back.svg", )"# ); let path = dir.join(file_name); let file = std::fs::File::create(&path).expect("create zip"); let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default(); writer.start_file("theme.ron", options).expect("start_file"); writer .write_all(manifest.as_bytes()) .expect("write manifest"); writer.finish().expect("finish zip"); path } /// Spawn the test server with a theme store scanned from `store_dir` /// and return its base URL. async fn spawn_store_server(store_dir: &Path) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("failed to bind test listener"); let addr = listener.local_addr().expect("listener has no local addr"); let pool = solitaire_server::build_test_pool().await; let app = solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir)); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { eprintln!("test server crashed: {e}"); } }); format!("http://{addr}") } /// Catalog fetch → download → verified bytes match the file the /// server scanned. This is the exact path the engine's store UI runs. #[tokio::test] async fn catalog_fetch_and_verified_download_round_trip() { let dir = tempfile::tempdir().expect("tempdir"); let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); let base_url = spawn_store_server(dir.path()).await; let client = ThemeStoreClient::new(&base_url); let catalog = client.fetch_catalog().await.expect("fetch catalog"); assert_eq!(catalog.len(), 1); let entry = &catalog[0]; assert_eq!(entry.id, "neon"); let bytes = client.download_theme(entry).await.expect("download"); assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip")); } /// A catalog entry whose hash no longer matches the served file must /// be rejected client-side — the importer never sees unverified bytes. #[tokio::test] async fn download_with_stale_catalog_hash_is_rejected() { let dir = tempfile::tempdir().expect("tempdir"); write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); let base_url = spawn_store_server(dir.path()).await; let client = ThemeStoreClient::new(&base_url); let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone(); entry.sha256 = "0".repeat(64); let err = client .download_theme(&entry) .await .expect_err("mismatched hash must fail"); assert!( matches!( err, solitaire_data::ThemeStoreError::ChecksumMismatch { .. } ), "expected ChecksumMismatch, got: {err:?}" ); } /// An empty (or absent) store directory serves an empty catalog — the /// client sees a store with nothing in it, not an error. #[tokio::test] async fn empty_store_yields_empty_catalog() { let dir = tempfile::tempdir().expect("tempdir"); let base_url = spawn_store_server(dir.path()).await; let client = ThemeStoreClient::new(&base_url); let catalog = client.fetch_catalog().await.expect("fetch catalog"); assert!(catalog.is_empty()); }