d87397b382
Test / test (pull_request) Successful in 10m15s
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).
- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
(additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
download on AsyncComputeTaskPool, atomic .tmp+rename write into
user_theme_dir, then the existing hardened import_theme pipeline and
an in-place registry refresh
New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
4.1 KiB
Rust
119 lines
4.1 KiB
Rust
//! 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());
|
|
}
|