From d571726c87a3c0f6d55a5b51dfeb4f16eb385342 Mon Sep 17 00:00:00 2001 From: "jonas.puksta.sensmetry" Date: Thu, 9 Apr 2026 14:01:43 +0300 Subject: [PATCH 1/2] transition to new project mocking methods work in progress --- .vscode/settings.json | 7 + Cargo.lock | 209 +++++++++ core/Cargo.toml | 7 + core/src/commands/include.rs | 26 +- core/src/lib.rs | 2 + core/src/project/local_kpar.rs | 43 +- core/src/project/reqwest_kpar_download.rs | 35 +- core/src/project/reqwest_src.rs | 66 ++- core/src/test_utils.rs | 533 ++++++++++++++++++++++ sysand/Cargo.toml | 1 + sysand/tests/cli_clone.rs | 5 + sysand/tests/cli_info.rs | 23 +- sysand/tests/cli_sync.rs | 30 +- 13 files changed, 857 insertions(+), 130 deletions(-) create mode 100644 core/src/test_utils.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index f12ff70e..e918ef5c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,16 @@ { "cSpell.words": [ + "camino", "clippy", + "httpmock", "kerml", + "Kommandöh", + "Kömmandöh", "kpar", + "libtest", "mdbook", + "Mekanïk", + "Mëkanïk", "metamodel", "Ppmd", "pubgrub", diff --git a/Cargo.lock b/Cargo.lock index 29c03c0e..17abffcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,27 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" +dependencies = [ + "async-lock", + "event-listener", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -233,6 +254,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "bzip2" @@ -413,6 +437,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -483,6 +516,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -617,6 +656,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -772,6 +832,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -1672,6 +1738,30 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heapless" version = "0.8.0" @@ -1742,6 +1832,40 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-trait", + "base64", + "bytes", + "crossbeam-utils", + "form_urlencoded", + "futures-timer", + "futures-util", + "headers", + "http", + "http-body-util", + "hyper", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "stringmetrics", + "tabwriter", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2399,6 +2523,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2422,6 +2552,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3135,6 +3274,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3200,6 +3349,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3255,6 +3414,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringmetrics" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" + [[package]] name = "strsim" version = "0.11.1" @@ -3351,6 +3516,7 @@ dependencies = [ "futures", "gix", "globset", + "httpmock", "indexmap", "log", "logos", @@ -3366,6 +3532,7 @@ dependencies = [ "serde_json", "sha2", "spdx", + "sysand-core", "sysand-macros", "tempfile", "thiserror 2.0.18", @@ -3374,6 +3541,7 @@ dependencies = [ "toml_edit", "typed-path", "url", + "urlencoding", "walkdir", "wasm-bindgen", "zip", @@ -3466,6 +3634,15 @@ dependencies = [ "libc", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -3587,10 +3764,23 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3719,10 +3909,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3807,6 +4010,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/core/Cargo.toml b/core/Cargo.toml index b52cebc5..a1d00c07 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -28,6 +28,7 @@ kpar-zstd = ["zip?/zstd"] kpar-xz = ["zip?/xz"] kpar-ppmd = ["zip?/ppmd"] alltests = [] +test-utils = ["dep:httpmock", "dep:urlencoding"] [dependencies] # General @@ -69,8 +70,14 @@ toml_edit = { version = "0.25.4", features = ["serde"] } globset = { version = "0.4.18", default-features = false } reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream"] } dunce = "1.0.5" +# For test-utils +httpmock = { version = "0.8.3", optional = true } +urlencoding = { version = "2.1.3", optional = true } [dev-dependencies] +# Based on https://github.com/rust-lang/cargo/issues/2911#issuecomment-749580481 +# and https://github.com/rust-lang/cargo/issues/2911#issuecomment-1483256987 +sysand-core = { path = ".", default-features = false, features = ["test-utils"] } assert_cmd = "2.1.2" mockito = "1.7.2" port_check = "0.3.0" diff --git a/core/src/commands/include.rs b/core/src/commands/include.rs index 1ab2a856..9f66859e 100644 --- a/core/src/commands/include.rs +++ b/core/src/commands/include.rs @@ -51,27 +51,17 @@ pub fn do_include>( log::info!("{header}{including:>12}{header:#} file `{}`", path.as_ref()); if index_symbols { - match force_format.or_else(|| Language::guess_from_path(&path)) { - Some(Language::SysML) => { - let new_symbols = crate::symbols::top_level_sysml( - project.read_source(&path).map_err(IncludeError::Project)?, - ) - .map_err(|e| IncludeError::Extract(path.as_ref().as_str().into(), e))?; - - project.merge_index(new_symbols.into_iter().map(|x| (x, path.as_ref())), true)?; - } - Some(Language::KerML) => { - let new_symbols = crate::symbols::top_level_kerml( - project.read_source(&path).map_err(IncludeError::Project)?, - ) - .map_err(|e| IncludeError::Extract(path.as_ref().as_str().into(), e))?; - - project.merge_index(new_symbols.into_iter().map(|x| (x, path.as_ref())), true)?; - } + let reader = project.read_source(&path).map_err(IncludeError::Project)?; + let new_symbols = match force_format.or_else(|| Language::guess_from_path(&path)) { + Some(Language::SysML) => crate::symbols::top_level_sysml(reader), + Some(Language::KerML) => crate::symbols::top_level_kerml(reader), _ => { return Err(IncludeError::UnknownFormat(path.as_ref().as_str().into())); } - } + }; + let new_symbols = + new_symbols.map_err(|e| IncludeError::Extract(path.as_ref().as_str().into(), e))?; + project.merge_index(new_symbols.into_iter().map(|x| (x, path.as_ref())), true)?; } project.include_source(&path, compute_checksum, true)?; diff --git a/core/src/lib.rs b/core/src/lib.rs index b3d63656..1b40b1dc 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -29,6 +29,8 @@ pub mod discover; #[cfg(not(feature = "std"))] compile_error!("`std` feature is currently required to build `sysand`"); +#[cfg(feature = "test-utils")] +pub mod test_utils; // #[cfg(feature = "python")] // use pyo3::prelude::*; diff --git a/core/src/project/local_kpar.rs b/core/src/project/local_kpar.rs index 66ed1e02..badb4d10 100644 --- a/core/src/project/local_kpar.rs +++ b/core/src/project/local_kpar.rs @@ -386,12 +386,13 @@ impl ProjectRead for LocalKParProject { #[cfg(test)] mod tests { - use std::io::{Read as _, Write}; + use std::{fs, io::Read as _}; use camino_tempfile::tempdir; use zip::write::SimpleFileOptions; use super::ProjectRead; + use crate::test_utils::ProjectMock; #[test] fn test_basic_kpar_archive() -> Result<(), Box> { @@ -399,21 +400,17 @@ mod tests { let zip_path = cwd.path().join("test.kpar"); { - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip = zip::ZipWriter::new(file); + let project_mock = ProjectMock::builder("test_basic_kpar_archive", "1.2.3") + .with_created("123") + .with_files([("test.sysml", "package Test;")], false, false) + .build(); let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored) .unix_permissions(0o755); - zip.start_file(".project.json", options)?; - zip.write_all(br#"{"name":"test_basic_kpar_archive","version":"1.2.3","usage":[]}"#)?; - zip.start_file(".meta.json", options)?; - zip.write_all(br#"{"index":{},"created":"123"}"#)?; - zip.start_file("test.sysml", options)?; - zip.write_all(br#"package Test;"#)?; - - zip.finish().unwrap(); + let zip_content = project_mock.to_zip(options)?; + fs::write(&zip_path, zip_content)?; } let project = super::LocalKParProject::new_guess_root(zip_path)?; @@ -442,21 +439,23 @@ mod tests { let zip_path = cwd.path().join("test.kpar"); { - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip = zip::ZipWriter::new(file); + let project = ProjectMock::new_raw([ + ( + "some_root_dir/.project.json", + r#"{"name":"test_nested_kpar_archive","version":"1.2.3","usage":[]}"#, + ), + ( + "some_root_dir/.meta.json", + r#"{"index":{},"created":"123"}"#, + ), + ("some_root_dir/test.sysml", r#"package Test;"#), + ]); let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored) .unix_permissions(0o755); - - zip.start_file("some_root_dir/.project.json", options)?; - zip.write_all(br#"{"name":"test_nested_kpar_archive","version":"1.2.3","usage":[]}"#)?; - zip.start_file("some_root_dir/.meta.json", options)?; - zip.write_all(br#"{"index":{},"created":"123"}"#)?; - zip.start_file("some_root_dir/test.sysml", options)?; - zip.write_all(br#"package Test;"#)?; - - zip.finish().unwrap(); + let zip_content = project.to_zip(options)?; + fs::write(&zip_path, zip_content)?; } let project = super::LocalKParProject::new_guess_root(zip_path)?; diff --git a/core/src/project/reqwest_kpar_download.rs b/core/src/project/reqwest_kpar_download.rs index 47412dba..18639f12 100644 --- a/core/src/project/reqwest_kpar_download.rs +++ b/core/src/project/reqwest_kpar_download.rs @@ -177,40 +177,33 @@ impl ProjectReadAsync for ReqwestKparDownloadedProje #[cfg(test)] mod tests { - use std::{ - io::{Read, Write as _}, - sync::Arc, - }; - use crate::{ auth::Unauthenticated, project::{ProjectRead, ProjectReadAsync}, resolve::net_utils::create_reqwest_client, + test_utils::ProjectMock, }; + use std::{io::Read, sync::Arc}; #[test] fn test_basic_download_request() -> Result<(), Box> { let buf = { - let mut cursor = std::io::Cursor::new(vec![]); - let mut zip = zip::ZipWriter::new(&mut cursor); - + let project = ProjectMock::new_raw([ + ( + "some_root_dir/.project.json", + r#"{"name":"test_basic_download_request","version":"1.2.3","usage":[]}"#, + ), + ( + "some_root_dir/.meta.json", + r#"{"index":{},"created":"123"}"#, + ), + ("some_root_dir/test.sysml", r#"package Test;"#), + ]); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored) .unix_permissions(0o755); - zip.start_file("some_root_dir/.project.json", options)?; - zip.write_all( - br#"{"name":"test_basic_download_request","version":"1.2.3","usage":[]}"#, - )?; - zip.start_file("some_root_dir/.meta.json", options)?; - zip.write_all(br#"{"index":{},"created":"123"}"#)?; - zip.start_file("some_root_dir/test.sysml", options)?; - zip.write_all(br#"package Test;"#)?; - - zip.finish().unwrap(); - - cursor.flush()?; - cursor.into_inner() + project.to_zip(options)? }; let mut server = mockito::Server::new(); diff --git a/core/src/project/reqwest_src.rs b/core/src/project/reqwest_src.rs index 71488c33..abc13c55 100644 --- a/core/src/project/reqwest_src.rs +++ b/core/src/project/reqwest_src.rs @@ -204,6 +204,7 @@ impl ProjectReadAsync for ReqwestSrcProjectAsync Result<(), Box> { - let server = mockito::Server::new(); + let server = MockServer::start(); - let url = reqwest::Url::parse(&server.url()).unwrap(); + let url = reqwest::Url::parse(&server.base_url()).unwrap(); let client = create_reqwest_client()?; @@ -241,36 +243,16 @@ mod tests { #[test] fn test_basic_project_urls_http_src() -> Result<(), Box> { - let mut server = mockito::Server::new(); - - //let host = server.host_with_port(); - let url = reqwest::Url::parse(&server.url()).unwrap(); - - let info_mock = server - .mock("GET", "/.project.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"name":"test_basic_project_urls","version":"1.2.3","usage":[]}"#) - .match_request(|r| r.has_header(header::USER_AGENT)) - .create(); - - let meta_mock = server - .mock("GET", "/.meta.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) - .match_request(|r| r.has_header(header::USER_AGENT)) - .create(); - - let src = "package 'Mekanïk Kommandöh';"; - - let src_mock = server - .mock("GET", "/Mekan%C3%AFk/Kommand%C3%B6h.sysml") - .with_status(200) - .with_header("content-type", "text/plain") - .with_body(src) - .match_request(|r| r.has_header(header::USER_AGENT)) - .create(); + let file_path = "Mekanïk/Kommandöh.sysml"; + let file_src = "package 'Mekanïk Kommandöh';"; + + let project_mock = ProjectMock::builder("test_basic_project_urls", "1.2.3") + .with_created("0000-00-00T00:00:00.123456789Z") + .with_files([(file_path, file_src)], true, true) + .build(); + let server = MockServer::start(); + let url = reqwest::Url::parse(&server.base_url()).unwrap(); + let mocks = project_mock.add_to_server(&server, |when| when.header_exists(header::USER_AGENT.as_str()), |then| then); let client = create_reqwest_client()?; @@ -285,8 +267,14 @@ mod tests { .build()?, )); - let (Some(info), Some(meta)) = project.get_project()? else { - panic!() + // let (Some(info), Some(meta)) = project.get_project()?; + + let downloaded_project = project.get_project()?; + let (Some(info), Some(meta)) = downloaded_project else { + panic!( + "Info and meta must not be None. Info: {:?}, Meta: {:?}", + downloaded_project.0, downloaded_project.1 + ) }; assert_eq!(info.name, "test_basic_project_urls"); @@ -294,10 +282,10 @@ mod tests { let mut src_buf = String::new(); project - .read_source(Utf8UnixPath::new("Mekanïk/Kommandöh.sysml").to_path_buf())? + .read_source(Utf8UnixPath::new(file_path).to_path_buf())? .read_to_string(&mut src_buf)?; - assert_eq!(src, src_buf); + assert_eq!(file_src, src_buf); let Err(super::ReqwestSrcError::BadStatus(..)) = project.read_source(Utf8UnixPath::new("Mekanik/Kommandoh.sysml").to_path_buf()) @@ -305,9 +293,9 @@ mod tests { panic!(); }; - info_mock.assert(); - meta_mock.assert(); - src_mock.assert(); + for (_path, mock) in mocks.iter() { + mock.assert_calls(1); + } Ok(()) } diff --git a/core/src/test_utils.rs b/core/src/test_utils.rs new file mode 100644 index 00000000..c8c9f95b --- /dev/null +++ b/core/src/test_utils.rs @@ -0,0 +1,533 @@ +use camino::Utf8PathBuf; +pub use httpmock; +use httpmock::{ + Method::{GET, HEAD}, Mock, MockServer, Then, When +}; +use indexmap::{IndexMap, map::Entry}; +use std::{collections::HashMap, fs, io::Write}; +use typed_path::{Utf8UnixPath, Utf8UnixPathBuf}; +use urlencoding::encode; +use zip::write::SimpleFileOptions; + +use crate::{ + include::do_include, + model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw, InterchangeProjectUsageG}, + project::ProjectMut, + project::memory::InMemoryProject, +}; + +// pub type ProjectMock = InMemoryProject; + +// Use this instead of InMemoryProject to allow malformed .project.json and .meta.json +pub struct ProjectMock { + pub all_files: HashMap, +} + +pub struct ProjectMockBuilder { + pub in_memory_project: InMemoryProject, +} + +fn into(option: Option>) -> Option { + option.map(|value| value.into()) +} + +impl ProjectMockBuilder { + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + in_memory_project: InMemoryProject::from_info_meta( + InterchangeProjectInfoRaw::minimal(name.into(), version.into()), + InterchangeProjectMetadataRaw { + index: IndexMap::default(), + created: chrono::Utc::now().to_rfc3339(), + metamodel: None, + includes_derived: None, + includes_implied: None, + checksum: None, + }, + ), + } + } + + pub fn info(self: &Self) -> &InterchangeProjectInfoRaw { + self.in_memory_project.info.as_ref().unwrap() + } + + pub fn info_mut(self: &mut Self) -> &mut InterchangeProjectInfoRaw { + self.in_memory_project.info.as_mut().unwrap() + } + + pub fn meta(self: &Self) -> &InterchangeProjectMetadataRaw { + self.in_memory_project.meta.as_ref().unwrap() + } + + pub fn meta_mut(self: &mut Self) -> &mut InterchangeProjectMetadataRaw { + self.in_memory_project.meta.as_mut().unwrap() + } + + pub fn files(self: &Self) -> &HashMap { + &self.in_memory_project.files + } + + pub fn files_mut(self: &mut Self) -> &mut HashMap { + &mut self.in_memory_project.files + } + + pub fn with_description(self: &mut Self, description: Option>) -> &mut Self { + self.info_mut().description = into(description); + self + } + + pub fn with_license(self: &mut Self, license: Option>) -> &mut Self { + self.info_mut().license = into(license); + self + } + + pub fn with_maintainer( + self: &mut Self, + maintainer: impl IntoIterator>, + ) -> &mut Self { + self.info_mut().maintainer = maintainer.into_iter().map(|m| m.into()).collect(); + self + } + + pub fn with_website(self: &mut Self, website: Option>) -> &mut Self { + self.info_mut().website = into(website); + self + } + + pub fn with_topic( + self: &mut Self, + topic: impl IntoIterator>, + ) -> &mut Self { + self.info_mut().topic = topic.into_iter().map(|t| t.into()).collect(); + self + } + + pub fn with_usage( + self: &mut Self, + usage: impl IntoIterator, Option>)>, + ) -> &mut Self { + self.info_mut() + .usage + .extend( + usage + .into_iter() + .map(|(dep, ver)| InterchangeProjectUsageG { + resource: dep.into(), + version_constraint: into(ver), + }), + ); + self + } + + pub fn with_created(self: &mut Self, created: impl Into) -> &mut Self { + self.meta_mut().created = created.into(); + self + } + + pub fn with_metamodel(self: &mut Self, metamodel: Option>) -> &mut Self { + self.meta_mut().metamodel = into(metamodel); + self + } + + pub fn with_includes_derived(self: &mut Self, includes_derived: Option) -> &mut Self { + self.meta_mut().includes_derived = includes_derived; + self + } + + pub fn with_includes_implied(self: &mut Self, includes_implied: Option) -> &mut Self { + self.meta_mut().includes_implied = includes_implied; + self + } + + pub fn with_index_create_files( + self: &mut Self, + index: impl IntoIterator, impl Into)>, + compute_checksum: bool, + ) -> &mut Self { + for (symbol, path) in index.into_iter() { + let symbol = symbol.into(); + let path = path.into(); + let index_entry = self.meta_mut().index.entry(symbol.clone()); + if let Entry::Occupied(index_entry) = index_entry { + panic!("Index entry with key {} already exists", index_entry.key()); + } + index_entry.insert_entry(path.clone()); + let files_entry = self.files_mut().entry(path.clone().into()); + let file_content = files_entry.or_insert(String::new()); + if !file_content.is_empty() && !file_content.ends_with('\n') { + file_content.push('\n'); + } + file_content.push_str(&format!("package '{symbol}'\n")); + self.in_memory_project + .include_source(Utf8UnixPath::new(&path), compute_checksum, true) + .unwrap(); + } + self + } + + pub fn with_files<'a>( + self: &mut Self, + files: impl IntoIterator + 'a)>, + compute_checksum: bool, + index_symbols: bool, + ) -> &mut Self { + files.into_iter().for_each(|(path, content)| { + self.files_mut().insert(path.into(), content.into()); + do_include( + &mut self.in_memory_project, + Utf8UnixPath::new(path), + compute_checksum, + index_symbols, + None, + ) + .unwrap() + }); + + self + } + + // pub fn compute_checksum(self: &mut Self) -> &mut Self { + // self.meta_mut() + // .add_checksum(path, algorithm, value, overwrite) + // } + + pub fn build(self: &Self) -> ProjectMock { + ProjectMock::new_raw( + [ + ( + &".project.json".into(), + &serde_json::to_string(&self.info()).unwrap(), + ), + ( + &".meta.json".into(), + &serde_json::to_string(&self.meta()).unwrap(), + ), + ] + .into_iter() + .chain(self.files().iter()), + ) + } + + // pub fn new(name: impl Into, version: impl Into) -> Self { + // Self { + // project_info: InterchangeProjectInfoRaw::minimal(name.into(), version.into()), + // project_metadata: InterchangeProjectMetadataRaw::generate_blank(), + // other_files: HashMap::new(), + // } + // } + + // pub fn with_created(self: &mut Self, created: impl Into) -> &mut Self { + // self.project_metadata.created = created.into(); + // self + // } + + // pub fn with_usage( + // self: &mut Self, + // usage: impl IntoIterator, Option>)>, + // ) -> &mut Self { + // self.project_info + // .usage + // .extend( + // usage + // .into_iter() + // .map(|(dep, ver)| InterchangeProjectUsageG { + // resource: dep.into(), + // version_constraint: ver.map(|val| val.into()), + // }), + // ); + // self + // } + + // pub fn with_index_create_files( + // self: &mut Self, + // index: impl IntoIterator, impl Into)>, + // ) -> &mut Self { + // for (symbol, file_path) in index.into_iter() { + // // let file_path_buf = Utf8PathBuf::from(file_path); + // let symbol = symbol.into(); + // let file_path = file_path.into(); + // let index_entry = self.project_metadata.index.entry(symbol.clone()); + // if let Entry::Occupied(index_entry) = index_entry { + // panic!("Index entry with key {} already exists", index_entry.key()); + // } + // index_entry.insert_entry(file_path.clone()); + // let files_entry = self.other_files.entry(file_path.into()); + // let file_content = files_entry.or_insert(String::new()); + // if !file_content.is_empty() && !file_content.ends_with('\n') { + // file_content.push('\n'); + // } + // file_content.push_str(&format!("package '{symbol}'\n")); + // } + // self + // } + + // pub fn with_files<'a>( + // self: &mut Self, + // files: impl IntoIterator, + // ) -> &mut Self { + // self.other_files.extend( + // files + // .into_iter() + // .map(|(path, content)| (path.to_string(), content.to_string())), + // ); + // self + // } + + // pub fn build(self: &Self) -> ProjectMock { + // ProjectMock::new_raw( + // [ + // ( + // &".project.json".to_string(), + // &serde_json::to_string(&self.project_info).unwrap(), + // ), + // ( + // &".meta.json".to_string(), + // &serde_json::to_string(&self.project_metadata).unwrap(), + // ), + // ] + // .into_iter() + // .chain(self.other_files.iter()), + // ) + // } + + // pub fn build_to_folder(self: &Self, folder: &str) -> ProjectMock {} +} + +// pub struct ProjectInfoMock { +// name: String, +// version: String, +// usage: Vec<(String, String)>, +// } + +// pub struct ProjectMetaMock { +// index: IndexMap, +// created: String, +// checksum: IndexMap, +// } + +// pub struct ProjectOverHttpMock { +// bla: Mock, +// } + +impl ProjectMock { + // pub fn new_raw<'a>(files: impl IntoIterator) -> Self { + // Self { + // files: files + // .into_iter() + // .map(|(path, content)| (Utf8PathBuf::from(path), content.to_string())) + // .collect(), + // } + // } + + pub fn new_raw( + files: impl IntoIterator, impl Into)>, + ) -> Self { + Self { + all_files: files + .into_iter() + .map(|(path, content)| (path.into(), content.into())) + .collect(), + } + } + + pub fn builder(name: impl Into, version: impl Into) -> ProjectMockBuilder { + ProjectMockBuilder::new(name, version) + } + + pub fn new_small_example() -> Self { + Self::builder("Lib test", "0.0.1") + .with_index_create_files( + [ + ("Foo", "extras/foo.sysml"), + ("LibTest", "libtest.sysml"), + ("Baz", "extras/bar/baz.sysml"), + ], + true, + ) + .build() + + // Self::new_high_level( + // "Lib test", + // "0.0.1", + // &[], + // &[ + // ("Foo", "extras/foo.sysml"), + // ("LibTest", "libtest.sysml"), + // ("Baz", "extras/bar/baz.sysml"), + // ], + // ) + + // Self::new( + // InterchangeProjectInfoRaw::minimal("Lib test".to_string(), "0.0.1".to_string()), + // InterchangeProjectMetadataRaw { + // index: [ + // ("Foo".to_string(), "extras/foo.sysml".to_string()), + // ("LibTest".to_string(), "libtest.sysml".to_string()), + // ("Baz".to_string(), "extras/bar/baz.sysml".to_string()), + // ] + // .into_iter() + // .collect(), + // ..InterchangeProjectMetadataRaw::generate_blank() + // }, + // &[ + // ( + // ".project.json", + // r#"{"name": "Lib test","version": "0.0.1","usage": []}"#, + // ), + // ( + // ".meta.json", + // r#"{"index":{"Foo":"extras/foo.sysml","LibTest":"libtest.sysml","Baz":"extras/bar/baz.sysml"},"created":"2025-05-30T12:34:24.977672Z"}"#, + // ), + // ( + // "libtest.sysml", + // r#"package LibTest { attribute desc = "Just testing"; }"#, + // ), + // ( + // "extras/foo.sysml", + // r#"package Foo { attribute desc = "More foo."; }"#, + // ), + // ( + // "extras/bar/baz.sysml", + // r#"package Baz { attribute desc = "Bar Baz!"; }"#, + // ), + // ], + // ) + + // Self::new_raw(&[ + // ( + // ".project.json", + // r#"{"name": "Lib test","version": "0.0.1","usage": []}"#, + // ), + // ( + // ".meta.json", + // r#"{"index":{"Foo":"extras/foo.sysml","LibTest":"libtest.sysml","Baz":"extras/bar/baz.sysml"},"created":"2025-05-30T12:34:24.977672Z"}"#, + // ), + // ( + // "libtest.sysml", + // r#"package LibTest { attribute desc = "Just testing"; }"#, + // ), + // ( + // "extras/foo.sysml", + // r#"package Foo { attribute desc = "More foo."; }"#, + // ), + // ( + // "extras/bar/baz.sysml", + // r#"package Baz { attribute desc = "Bar Baz!"; }"#, + // ), + // ]) + } + + // // TODO: perhaps add a callback to potentially modify the paths? E.g. add auth, change return code, etc. + // pub fn add_to_mock_server(self: &Self, server: &mut ServerGuard) -> ProjectOverHttpMock { + // todo!(); + // } + + pub fn save_to_folder(self: &Self, root_path: &Utf8PathBuf) { + for (path, contents) in self.all_files.iter() { + let full_path: Utf8PathBuf = [root_path, path].iter().collect(); + fs::write(full_path, contents).unwrap(); + } + } + + pub fn to_zip( + self: &Self, + options: SimpleFileOptions, + ) -> Result, Box> { + let mut cursor = std::io::Cursor::new(vec![]); + let mut zip = zip::ZipWriter::new(&mut cursor); + + // let options = zip::write::SimpleFileOptions::default() + // .compression_method(zip::CompressionMethod::Stored) + // .unix_permissions(0o755); + + for (file_name, file_contents) in self.all_files.iter() { + zip.start_file(file_name, options)?; + zip.write_all(file_contents.as_bytes())?; + } + zip.finish().unwrap(); + + cursor.flush()?; + Ok(cursor.into_inner()) + } + + fn path_to_url_path(path: &Utf8PathBuf) -> String { + let mut url_path = String::new(); + for component in path.components() { + url_path += "/"; + url_path += &encode(component.as_str()); + } + return url_path; + // path.components() + // .map(|c| encode(c.as_str()).to_string()) + // .join("/"); + } + + // fn get_request_file_path(req: &HttpMockRequest) -> Utf8PathBuf { + // let uri = req.uri(); + // let path = uri.path(); + // dbg!(path); + // Utf8PathBuf::from(&path[1..]) + // } + + pub fn add_to_server<'a>(&self, server: &'a MockServer, mut when_fn: impl FnMut(When) -> When, mut then_fn: impl FnMut(Then) -> Then) -> HashMap> { + self.all_files + .iter() + .map(|(path, content)| { + // for (path, content) in self.all_files.iter() { + let mock = server.mock(|when, then| { + // println!("{}", Self::path_to_url_path(path)); + when.and(&mut when_fn).method(GET) + // .method(HEAD) + .path(Self::path_to_url_path(path)); + // .path(["/", path.as_str()].concat()); + let content_type = if path.ends_with(".json") { + "application/json" + } else { + "text/plain" + }; + then.and(&mut then_fn).status(200) + .header("content-type", content_type) + .body(content); + }); + (path.into(), mock) + }) + .collect() + + // let all_files1 = self.all_files.clone(); + // let all_files2 = self.all_files.clone(); + + // let mock = server.mock(move |when, then| { + // when.is_true(move |req| { + // matches!(req.method(), Method::HEAD | Method::GET) + // && all_files1.contains_key(&Self::get_request_file_path(req)) + // }); + // then.respond_with(move |req| { + // let path = Self::get_request_file_path(req); + // let body = all_files2[&path].clone(); + // let content_type = if path.ends_with(".json") { + // "application/json" + // } else { + // "text/plain" + // }; + // // If it was a HEAD request, the server will only send the headers automatically + // HttpMockResponse::builder() + // .status(200) + // .header("content-type", content_type) + // .body(body) + // .build() + // // if matches!(req.method(), Method::GET) { + // // let body = all_files2[&path].clone(); + // // let content_type = if path.ends_with(".json") { + // // "application/json" + // // } else { + // // "text/plain" + // // }; + // // response = response.header("content-type", content_type).body(body); + // // } + // // response.build() + // }); + // }); + // return mock; + } +} diff --git a/sysand/Cargo.toml b/sysand/Cargo.toml index cf341ecd..7f3e1d3a 100644 --- a/sysand/Cargo.toml +++ b/sysand/Cargo.toml @@ -46,6 +46,7 @@ reqwest-middleware = { version = "0.5.1" } reqwest = { version = "0.13.2", features = ["rustls", "blocking"] } [dev-dependencies] +sysand-core = { path = "../core", default-features = false, features = ["test-utils"] } assert_cmd = "2.1.2" glob = "0.3.3" mockito = "1.7.2" diff --git a/sysand/tests/cli_clone.rs b/sysand/tests/cli_clone.rs index 72d311e1..0f3e5cbd 100644 --- a/sysand/tests/cli_clone.rs +++ b/sysand/tests/cli_clone.rs @@ -57,7 +57,12 @@ fn assert_dir_empty(p: impl AsRef) -> Result<(), Box Result<(), Box> { + // let project = ProjectMock::new_small_example(); + // let temp_dir = camino_tempfile::TempDir::new()?; + // Can't just use tempdir() as it returns the same directory every time + // let test_path = temp_dir.path(); let test_path = fixture_path("test_lib"); + // project.save_to_folder(&test_path); let test_path_str = test_path.as_str(); // auto path form locator let (_temp_dir, cwd, out) = run_sysand(["clone", test_path_str], None)?; diff --git a/sysand/tests/cli_info.rs b/sysand/tests/cli_info.rs index 81c483e6..426f1da3 100644 --- a/sysand/tests/cli_info.rs +++ b/sysand/tests/cli_info.rs @@ -4,7 +4,7 @@ #[cfg(feature = "alltests")] use std::process::Command; -use std::{error::Error, io::Write as _}; +use std::{error::Error, fs}; use assert_cmd::prelude::*; use camino::Utf8PathBuf; @@ -15,6 +15,7 @@ use predicates::prelude::*; // pub due to https://github.com/rust-lang/rust/issues/46379 mod common; pub use common::*; +use sysand_core::test_utils::ProjectMock; #[test] fn info_basic_in_cwd() -> Result<(), Box> { @@ -657,19 +658,23 @@ fn info_basic_local_kpar() -> Result<(), Box> { let zip_path = cwd.path().canonicalize()?.join("test.kpar"); { - let file = std::fs::File::create(&zip_path).unwrap(); - let mut zip = zip::ZipWriter::new(file); + let project = ProjectMock::new_raw([ + ( + "some_root_dir/.project.json", + r#"{"name":"info_basic_local_kpar","version":"1.2.3","usage":[]}"#, + ), + ( + "some_root_dir/.meta.json", + r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#, + ), + ]); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored) .unix_permissions(0o755); - zip.start_file("some_root_dir/.project.json", options)?; - zip.write_all(br#"{"name":"info_basic_local_kpar","version":"1.2.3","usage":[]}"#)?; - zip.start_file("some_root_dir/.meta.json", options)?; - zip.write_all(br#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#)?; - - zip.finish().unwrap(); + let zip_content = project.to_zip(options)?; + fs::write(&zip_path, zip_content)?; } let (_, _, out) = run_sysand(["info", "--path", &zip_path.to_string_lossy()], None)?; diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index 9e6cac14..f3c8081f 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -2,11 +2,13 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use assert_cmd::prelude::*; +use camino::Utf8PathBuf; use indexmap::IndexMap; use mockito::Matcher; use predicates::prelude::*; use reqwest::header; use sysand_core::{ + test_utils::{ProjectMock, httpmock::MockServer}, commands::lock::DEFAULT_LOCKFILE_NAME, env::local_directory::{DEFAULT_ENV_NAME, ENTRIES_PATH, METADATA_PATH}, }; @@ -161,25 +163,11 @@ identifiers = [ fn sync_to_remote() -> Result<(), Box> { let (_temp_dir, cwd) = new_temp_cwd()?; - let mut server = mockito::Server::new(); + let project_mock = ProjectMock::builder("sync_to_remote", "1.2.3").build(); - let info_mock = server - .mock("GET", "/.project.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"name":"sync_to_remote","version":"1.2.3","usage":[]}"#) - .expect_at_most(4) // TODO: Reduce this to 1 after caching - .match_request(|r| r.has_header(header::USER_AGENT)) - .create(); + let server = MockServer::start(); - let meta_mock = server - .mock("GET", "/.meta.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) - .expect_at_most(4) // TODO: Reduce this to 1 after caching - .match_request(|r| r.has_header(header::USER_AGENT)) - .create(); + let mocks = project_mock.add_to_server(&server, |when| when.header_exists(header::USER_AGENT.as_str()), |then| then); std::fs::write( cwd.join(DEFAULT_LOCKFILE_NAME), @@ -195,8 +183,8 @@ sources = [ {{ remote_src = "{}" }}, ] "#, - &server.url() - ), + &server.base_url() + ) )?; let out = run_sysand_in(&cwd, ["sync"], None)?; @@ -207,8 +195,8 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); - info_mock.assert(); - meta_mock.assert(); + mocks[&Utf8PathBuf::from("/.project.json")].assert_calls(4); // TODO: Reduce this to 1 after caching + mocks[&Utf8PathBuf::from("/.meta.json")].assert_calls(4); // TODO: Reduce this to 1 after caching let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; From 12ed81e90f0124666e6142056d7a95ca1005aa4f Mon Sep 17 00:00:00 2001 From: "jonas.puksta.sensmetry" Date: Fri, 17 Apr 2026 10:41:15 +0300 Subject: [PATCH 2/2] For project mock: improve the API to set the Created field, improve the zip API, and use project mock in more places --- .vscode/settings.json | 8 +- core/src/project/gix_git_download.rs | 23 +- core/src/project/local_kpar.rs | 45 ++- core/src/project/reqwest_kpar_download.rs | 27 +- core/src/project/reqwest_src.rs | 24 +- core/src/resolve/priority.rs | 76 ++--- core/src/resolve/sequential.rs | 168 +++++++---- core/src/test_utils.rs | 351 ++++++++++++++++------ sysand/tests/cli_info.rs | 123 +++----- sysand/tests/cli_sync.rs | 24 +- 10 files changed, 510 insertions(+), 359 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e918ef5c..0549eaa8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,9 @@ { "cSpell.words": [ + "bazs", "camino", "clippy", + "foos", "httpmock", "kerml", "Kommandöh", @@ -26,5 +28,9 @@ "werr", "wrapfs" ], - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + } } \ No newline at end of file diff --git a/core/src/project/gix_git_download.rs b/core/src/project/gix_git_download.rs index 1fc7d64f..40c1a8ff 100644 --- a/core/src/project/gix_git_download.rs +++ b/core/src/project/gix_git_download.rs @@ -187,14 +187,20 @@ mod tests { #[cfg(feature = "alltests")] #[test] pub fn basic_gix_access() -> Result<(), Box> { - let repo_dir = tempdir()?; - git_init(repo_dir.path())?; + use camino::{Utf8Path, Utf8PathBuf}; + + use crate::test_utils::{Created, ProjectMock}; + + let repo_dir = camino_tempfile::tempdir()?; + git_init(repo_dir.path().as_ref())?; + + let project = + ProjectMock::builder("basic_gix_access", "1.2.3", Created::Custom("123".into())) + .with_files([("test.sysml", "package Test;")], false, false) + .build(); + project.save_to_folder(repo_dir.path()); // TODO: Replace by commands::*::do_* when sufficiently complete, also use gix to create repo? - std::fs::write( - repo_dir.path().join(".project.json"), - r#"{"name":"basic_gix_access","version":"1.2.3","usage":[]}"#, - )?; Command::new("git") .arg("add") .arg(".project.json") @@ -203,10 +209,6 @@ mod tests { .assert() .success(); - std::fs::write( - repo_dir.path().join(".meta.json"), - r#"{"index":{},"created":"123"}"#, - )?; Command::new("git") .arg("add") .arg(".meta.json") @@ -215,7 +217,6 @@ mod tests { .assert() .success(); - std::fs::write(repo_dir.path().join("test.sysml"), "package Test;")?; Command::new("git") .arg("add") .arg("test.sysml") diff --git a/core/src/project/local_kpar.rs b/core/src/project/local_kpar.rs index badb4d10..47ccce08 100644 --- a/core/src/project/local_kpar.rs +++ b/core/src/project/local_kpar.rs @@ -389,10 +389,9 @@ mod tests { use std::{fs, io::Read as _}; use camino_tempfile::tempdir; - use zip::write::SimpleFileOptions; use super::ProjectRead; - use crate::test_utils::ProjectMock; + use crate::test_utils::{Created, ProjectMock, ZipOptions}; #[test] fn test_basic_kpar_archive() -> Result<(), Box> { @@ -400,16 +399,15 @@ mod tests { let zip_path = cwd.path().join("test.kpar"); { - let project_mock = ProjectMock::builder("test_basic_kpar_archive", "1.2.3") - .with_created("123") - .with_files([("test.sysml", "package Test;")], false, false) - .build(); - - let options = SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Stored) - .unix_permissions(0o755); + let project_mock = ProjectMock::builder( + "test_basic_kpar_archive", + "1.2.3", + Created::Custom("123".into()), + ) + .with_files([("test.sysml", "package Test;")], false, false) + .build(); - let zip_content = project_mock.to_zip(options)?; + let zip_content = project_mock.to_zip(ZipOptions::Default)?; fs::write(&zip_path, zip_content)?; } @@ -439,22 +437,15 @@ mod tests { let zip_path = cwd.path().join("test.kpar"); { - let project = ProjectMock::new_raw([ - ( - "some_root_dir/.project.json", - r#"{"name":"test_nested_kpar_archive","version":"1.2.3","usage":[]}"#, - ), - ( - "some_root_dir/.meta.json", - r#"{"index":{},"created":"123"}"#, - ), - ("some_root_dir/test.sysml", r#"package Test;"#), - ]); - - let options = SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Stored) - .unix_permissions(0o755); - let zip_content = project.to_zip(options)?; + let project = ProjectMock::builder( + "test_nested_kpar_archive", + "1.2.3", + Created::Custom("123".into()), + ) + .with_files([("test.sysml", "package Test;")], false, false) + .build(); + + let zip_content = project.to_zip_non_standard("some_root_dir", ZipOptions::Default)?; fs::write(&zip_path, zip_content)?; } diff --git a/core/src/project/reqwest_kpar_download.rs b/core/src/project/reqwest_kpar_download.rs index 18639f12..4acc9bc2 100644 --- a/core/src/project/reqwest_kpar_download.rs +++ b/core/src/project/reqwest_kpar_download.rs @@ -181,29 +181,22 @@ mod tests { auth::Unauthenticated, project::{ProjectRead, ProjectReadAsync}, resolve::net_utils::create_reqwest_client, - test_utils::ProjectMock, + test_utils::{Created, ProjectMock, ZipOptions}, }; use std::{io::Read, sync::Arc}; #[test] fn test_basic_download_request() -> Result<(), Box> { let buf = { - let project = ProjectMock::new_raw([ - ( - "some_root_dir/.project.json", - r#"{"name":"test_basic_download_request","version":"1.2.3","usage":[]}"#, - ), - ( - "some_root_dir/.meta.json", - r#"{"index":{},"created":"123"}"#, - ), - ("some_root_dir/test.sysml", r#"package Test;"#), - ]); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Stored) - .unix_permissions(0o755); - - project.to_zip(options)? + let project = ProjectMock::builder( + "test_basic_download_request", + "1.2.3", + Created::Custom("123".into()), + ) + .with_files([("test.sysml", "package Test;")], false, false) + .build(); + + project.to_zip_non_standard("some_root_dir", ZipOptions::Default)? }; let mut server = mockito::Server::new(); diff --git a/core/src/project/reqwest_src.rs b/core/src/project/reqwest_src.rs index abc13c55..c7d809b3 100644 --- a/core/src/project/reqwest_src.rs +++ b/core/src/project/reqwest_src.rs @@ -214,7 +214,7 @@ mod tests { auth::Unauthenticated, project::{ProjectRead, ProjectReadAsync, reqwest_src::ReqwestSrcProjectAsync}, resolve::net_utils::create_reqwest_client, - test_utils::ProjectMock, + test_utils::{Created, ProjectMock}, }; #[test] @@ -246,13 +246,20 @@ mod tests { let file_path = "Mekanïk/Kommandöh.sysml"; let file_src = "package 'Mekanïk Kommandöh';"; - let project_mock = ProjectMock::builder("test_basic_project_urls", "1.2.3") - .with_created("0000-00-00T00:00:00.123456789Z") - .with_files([(file_path, file_src)], true, true) - .build(); + let project_mock = ProjectMock::builder( + "test_basic_project_urls", + "1.2.3", + Created::Custom("0000-00-00T00:00:00.123456789Z".into()), + ) + .with_files([(file_path, file_src)], true, true) + .build(); let server = MockServer::start(); let url = reqwest::Url::parse(&server.base_url()).unwrap(); - let mocks = project_mock.add_to_server(&server, |when| when.header_exists(header::USER_AGENT.as_str()), |then| then); + let mocks = project_mock.add_files_to_server( + &server, + |when| when.header_exists(header::USER_AGENT.as_str()), + |then| then, + ); let client = create_reqwest_client()?; @@ -293,7 +300,10 @@ mod tests { panic!(); }; - for (_path, mock) in mocks.iter() { + for (_path, mock) in mocks.head.iter() { + mock.assert_calls(0); + } + for (_path, mock) in mocks.get.iter() { mock.assert_calls(1); } diff --git a/core/src/resolve/priority.rs b/core/src/resolve/priority.rs index 163a03eb..e1a184e5 100644 --- a/core/src/resolve/priority.rs +++ b/core/src/resolve/priority.rs @@ -203,62 +203,12 @@ impl ResolveRead for PriorityResolver, T: AsRef, V: AsRef>( - uri: S, - name: T, - version: V, - ) -> (Iri, InMemoryProject) { - ( - Iri::parse(uri.as_ref().to_string()).unwrap(), - InMemoryProject { - info: Some(InterchangeProjectInfoRaw { - name: name.as_ref().to_string(), - publisher: None, - description: None, - version: version.as_ref().to_string(), - license: None, - maintainer: vec![], - website: None, - topic: vec![], - usage: vec![], - }), - meta: Some(InterchangeProjectMetadataRaw { - index: IndexMap::default(), - created: chrono::Utc::now().to_rfc3339(), - metamodel: None, - includes_derived: None, - includes_implied: None, - checksum: Some(IndexMap::default()), - }), - files: HashMap::default(), - nominal_sources: vec![], - }, - ) - } - - fn mock_resolver, InMemoryProject)>>( - projects: I, - ) -> MemoryResolver { - MemoryResolver { - iri_predicate: AcceptAll {}, - projects: HashMap::from_iter(projects.into_iter().map(|(k, v)| (k, vec![v]))), - } - } - fn expect_to_resolve>( resolver: &R, uri: S, @@ -278,13 +228,25 @@ mod tests { #[test] fn resolution_priority() -> Result<(), Box> { let higher = mock_resolver([ - mock_project("urn:kpar:foo", "foo", "1.2.3"), - mock_project("urn:kpar:bar", "bar", "1.2.3"), + ( + "urn:kpar:foo", + ProjectMock::builder("foo", "1.2.3", Created::Now).build(), + ), + ( + "urn:kpar:bar", + ProjectMock::builder("bar", "1.2.3", Created::Now).build(), + ), ]); let lower = mock_resolver([ - mock_project("urn:kpar:bar", "bar", "3.2.1"), - mock_project("urn:kpar:baz", "baz", "3.2.1"), + ( + "urn:kpar:bar", + ProjectMock::builder("bar", "3.2.1", Created::Now).build(), + ), + ( + "urn:kpar:baz", + ProjectMock::builder("baz", "3.2.1", Created::Now).build(), + ), ]); let resolver = super::PriorityResolver::new(higher, lower); diff --git a/core/src/resolve/sequential.rs b/core/src/resolve/sequential.rs index 84f2b263..94938c7a 100644 --- a/core/src/resolve/sequential.rs +++ b/core/src/resolve/sequential.rs @@ -125,63 +125,12 @@ impl ResolveReadAsync for SequentialResolver { #[cfg(test)] mod tests { - use std::collections::HashMap; - - use fluent_uri::Iri; - use indexmap::IndexMap; - use crate::{ - model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw}, - project::{ProjectRead, memory::InMemoryProject}, - resolve::{ - ResolutionOutcome, ResolveRead, - memory::{AcceptAll, MemoryResolver}, - sequential::SequentialResolver, - }, + project::ProjectRead, + resolve::{ResolutionOutcome, ResolveRead, sequential::SequentialResolver}, + test_utils::{Created, ProjectMock, mock_resolver}, }; - fn mock_project, T: AsRef, V: AsRef>( - uri: S, - name: T, - version: V, - ) -> (Iri, InMemoryProject) { - ( - Iri::parse(uri.as_ref().to_string()).unwrap(), - InMemoryProject { - info: Some(InterchangeProjectInfoRaw { - name: name.as_ref().to_string(), - publisher: None, - description: None, - version: version.as_ref().to_string(), - license: None, - maintainer: vec![], - website: None, - topic: vec![], - usage: vec![], - }), - meta: Some(InterchangeProjectMetadataRaw { - index: IndexMap::default(), - created: chrono::Utc::now().to_rfc3339(), - metamodel: None, - includes_derived: None, - includes_implied: None, - checksum: Some(IndexMap::default()), - }), - files: HashMap::default(), - nominal_sources: vec![], - }, - ) - } - - fn mock_resolver, InMemoryProject)>>( - projects: I, - ) -> MemoryResolver { - MemoryResolver { - iri_predicate: AcceptAll {}, - projects: HashMap::from_iter(projects.into_iter().map(|(k, v)| (k, vec![v]))), - } - } - fn expect_to_resolve>( resolver: &R, uri: S, @@ -201,13 +150,25 @@ mod tests { #[test] fn test_resolution_preference() -> Result<(), Box> { let resolver_1 = mock_resolver([ - mock_project("urn:kpar:foo", "foo", "1.2.3"), - mock_project("urn:kpar:bar", "bar", "1.2.3"), + ( + "urn:kpar:foo", + ProjectMock::builder("foo", "1.2.3", Created::Minimum).build(), + ), + ( + "urn:kpar:bar", + ProjectMock::builder("bar", "1.2.3", Created::Minimum).build(), + ), ]); let resolver_2 = mock_resolver([ - mock_project("urn:kpar:bar", "bar", "3.2.1"), - mock_project("urn:kpar:baz", "baz", "3.2.1"), + ( + "urn:kpar:bar", + ProjectMock::builder("bar", "3.2.1", Created::Minimum).build(), + ), + ( + "urn:kpar:baz", + ProjectMock::builder("baz", "3.2.1", Created::Minimum).build(), + ), ]); let resolver = SequentialResolver::new([resolver_1, resolver_2]); @@ -230,4 +191,95 @@ mod tests { Ok(()) } + + // fn mock_project, T: AsRef, V: AsRef>( + // uri: S, + // name: T, + // version: V, + // ) -> (Iri, InMemoryProject) { + // ( + // Iri::parse(uri.as_ref().to_string()).unwrap(), + // InMemoryProject { + // info: Some(InterchangeProjectInfoRaw { + // name: name.as_ref().to_string(), + // publisher: None, + // description: None, + // version: version.as_ref().to_string(), + // license: None, + // maintainer: vec![], + // website: None, + // topic: vec![], + // usage: vec![], + // }), + // meta: Some(InterchangeProjectMetadataRaw { + // index: IndexMap::default(), + // created: chrono::Utc::now().to_rfc3339(), + // metamodel: None, + // includes_derived: None, + // includes_implied: None, + // checksum: Some(IndexMap::default()), + // }), + // files: HashMap::default(), + // nominal_sources: vec![], + // }, + // ) + // } + + // fn mock_resolver, InMemoryProject)>>( + // projects: I, + // ) -> MemoryResolver { + // MemoryResolver { + // iri_predicate: AcceptAll {}, + // projects: HashMap::from_iter(projects.into_iter().map(|(k, v)| (k, vec![v]))), + // } + // } + + // fn expect_to_resolve>( + // resolver: &R, + // uri: S, + // ) -> Vec { + // let resolved = resolver.resolve_read_raw(uri).unwrap(); + + // let foo_projects: Result, _> = + // if let ResolutionOutcome::Resolved(foo_projects) = resolved { + // foo_projects.into_iter().collect() + // } else { + // panic!("expected foo to resolve") + // }; + + // foo_projects.unwrap() + // } + + // #[test] + // fn test_resolution_preference() -> Result<(), Box> { + // let resolver_1 = mock_resolver([ + // mock_project("urn:kpar:foo", "foo", "1.2.3"), + // mock_project("urn:kpar:bar", "bar", "1.2.3"), + // ]); + + // let resolver_2 = mock_resolver([ + // mock_project("urn:kpar:bar", "bar", "3.2.1"), + // mock_project("urn:kpar:baz", "baz", "3.2.1"), + // ]); + + // let resolver = SequentialResolver::new([resolver_1, resolver_2]); + + // let foos = expect_to_resolve(&resolver, "urn:kpar:foo"); + + // assert_eq!(foos.len(), 1); + // assert_eq!(foos[0].version().unwrap(), Some("1.2.3".to_string())); + + // let bars = expect_to_resolve(&resolver, "urn:kpar:bar"); + + // assert_eq!(bars.len(), 2); + // assert_eq!(bars[0].version().unwrap(), Some("1.2.3".to_string())); + // assert_eq!(bars[1].version().unwrap(), Some("3.2.1".to_string())); + + // let bazs = expect_to_resolve(&resolver, "urn:kpar:baz"); + + // assert_eq!(bazs.len(), 1); + // assert_eq!(bazs[0].version().unwrap(), Some("3.2.1".to_string())); + + // Ok(()) + // } } diff --git a/core/src/test_utils.rs b/core/src/test_utils.rs index c8c9f95b..df94b099 100644 --- a/core/src/test_utils.rs +++ b/core/src/test_utils.rs @@ -1,24 +1,40 @@ -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; +use chrono::DateTime; +use fluent_uri::Iri; pub use httpmock; -use httpmock::{ - Method::{GET, HEAD}, Mock, MockServer, Then, When -}; +use httpmock::{Method::GET, Method::HEAD, Mock, MockServer, Then, When}; use indexmap::{IndexMap, map::Entry}; use std::{collections::HashMap, fs, io::Write}; +use thiserror::Error; use typed_path::{Utf8UnixPath, Utf8UnixPathBuf}; use urlencoding::encode; -use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, write::SimpleFileOptions}; use crate::{ + context::ProjectContext, include::do_include, + lock::Source, model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw, InterchangeProjectUsageG}, - project::ProjectMut, - project::memory::InMemoryProject, + project::{ProjectMut, ProjectRead, memory::InMemoryProject, utils::ToUnixPathBuf}, + resolve::memory::{AcceptAll, MemoryResolver}, }; // pub type ProjectMock = InMemoryProject; +const INFO_PATH_STR: &str = ".project.json"; +const METADATA_PATH_STR: &str = ".meta.json"; + +pub fn info_path() -> Utf8PathBuf { + Utf8PathBuf::from(INFO_PATH_STR) +} +pub fn metadata_path() -> Utf8PathBuf { + Utf8PathBuf::from(METADATA_PATH_STR) +} + // Use this instead of InMemoryProject to allow malformed .project.json and .meta.json +// The path type is interpreted as the current OS would interpret it +// So on Windows, the path might use Windows path separators +#[derive(Clone)] pub struct ProjectMock { pub all_files: HashMap, } @@ -31,14 +47,25 @@ fn into(option: Option>) -> Option { option.map(|value| value.into()) } +#[derive(Clone, Debug)] +pub enum Created { + Custom(String), + Minimum, + Now, +} + impl ProjectMockBuilder { - pub fn new(name: impl Into, version: impl Into) -> Self { + pub fn new(name: impl Into, version: impl Into, created: Created) -> Self { Self { in_memory_project: InMemoryProject::from_info_meta( InterchangeProjectInfoRaw::minimal(name.into(), version.into()), InterchangeProjectMetadataRaw { index: IndexMap::default(), - created: chrono::Utc::now().to_rfc3339(), + created: match created { + Created::Custom(time) => time, + Created::Minimum => DateTime::::MIN_UTC.to_rfc3339(), + Created::Now => chrono::Utc::now().to_rfc3339(), + }, metamodel: None, includes_derived: None, includes_implied: None, @@ -120,10 +147,10 @@ impl ProjectMockBuilder { self } - pub fn with_created(self: &mut Self, created: impl Into) -> &mut Self { - self.meta_mut().created = created.into(); - self - } + // pub fn with_created(self: &mut Self, created: impl Into) -> &mut Self { + // self.meta_mut().created = created.into(); + // self + // } pub fn with_metamodel(self: &mut Self, metamodel: Option>) -> &mut Self { self.meta_mut().metamodel = into(metamodel); @@ -196,11 +223,11 @@ impl ProjectMockBuilder { ProjectMock::new_raw( [ ( - &".project.json".into(), + &INFO_PATH_STR.into(), &serde_json::to_string(&self.info()).unwrap(), ), ( - &".meta.json".into(), + &METADATA_PATH_STR.into(), &serde_json::to_string(&self.meta()).unwrap(), ), ] @@ -294,21 +321,16 @@ impl ProjectMockBuilder { // pub fn build_to_folder(self: &Self, folder: &str) -> ProjectMock {} } -// pub struct ProjectInfoMock { -// name: String, -// version: String, -// usage: Vec<(String, String)>, -// } - -// pub struct ProjectMetaMock { -// index: IndexMap, -// created: String, -// checksum: IndexMap, -// } +#[derive(Clone, Copy, Debug)] +pub enum ZipOptions { + Custom(SimpleFileOptions), + Default, +} -// pub struct ProjectOverHttpMock { -// bla: Mock, -// } +pub struct Mocks<'a> { + pub head: HashMap>, + pub get: HashMap>, +} impl ProjectMock { // pub fn new_raw<'a>(files: impl IntoIterator) -> Self { @@ -331,12 +353,16 @@ impl ProjectMock { } } - pub fn builder(name: impl Into, version: impl Into) -> ProjectMockBuilder { - ProjectMockBuilder::new(name, version) + pub fn builder( + name: impl Into, + version: impl Into, + created: Created, + ) -> ProjectMockBuilder { + ProjectMockBuilder::new(name, version, created) } pub fn new_small_example() -> Self { - Self::builder("Lib test", "0.0.1") + Self::builder("Lib test", "0.0.1", Created::Minimum) .with_index_create_files( [ ("Foo", "extras/foo.sysml"), @@ -423,26 +449,47 @@ impl ProjectMock { // todo!(); // } - pub fn save_to_folder(self: &Self, root_path: &Utf8PathBuf) { + pub fn save_to_folder(self: &Self, root_path: &Utf8Path) { for (path, contents) in self.all_files.iter() { let full_path: Utf8PathBuf = [root_path, path].iter().collect(); fs::write(full_path, contents).unwrap(); } } - pub fn to_zip( + pub fn to_zip(self: &Self, options: ZipOptions) -> Result, Box> { + self.to_zip_internal(None, options) + } + + pub fn to_zip_non_standard( + self: &Self, + base_path: &str, + options: ZipOptions, + ) -> Result, Box> { + self.to_zip_internal(Some(base_path), options) + } + + fn to_zip_internal( self: &Self, - options: SimpleFileOptions, + base_path: Option<&str>, + options: ZipOptions, ) -> Result, Box> { let mut cursor = std::io::Cursor::new(vec![]); let mut zip = zip::ZipWriter::new(&mut cursor); - // let options = zip::write::SimpleFileOptions::default() - // .compression_method(zip::CompressionMethod::Stored) - // .unix_permissions(0o755); - - for (file_name, file_contents) in self.all_files.iter() { - zip.start_file(file_name, options)?; + let options = match options { + ZipOptions::Custom(options) => options, + ZipOptions::Default => SimpleFileOptions::default() + .compression_method(CompressionMethod::Stored) + .unix_permissions(0o755), + }; + + for (file_path, file_contents) in self.all_files.iter() { + let path = match base_path { + Some(base_path) => Utf8PathBuf::from(base_path).join(file_path), + None => file_path.clone(), + }; + let path = path.to_unix_path_buf(); + zip.start_file(path, options)?; zip.write_all(file_contents.as_bytes())?; } zip.finish().unwrap(); @@ -470,64 +517,168 @@ impl ProjectMock { // Utf8PathBuf::from(&path[1..]) // } - pub fn add_to_server<'a>(&self, server: &'a MockServer, mut when_fn: impl FnMut(When) -> When, mut then_fn: impl FnMut(Then) -> Then) -> HashMap> { - self.all_files - .iter() - .map(|(path, content)| { - // for (path, content) in self.all_files.iter() { - let mock = server.mock(|when, then| { - // println!("{}", Self::path_to_url_path(path)); - when.and(&mut when_fn).method(GET) - // .method(HEAD) - .path(Self::path_to_url_path(path)); - // .path(["/", path.as_str()].concat()); - let content_type = if path.ends_with(".json") { - "application/json" - } else { - "text/plain" - }; - then.and(&mut then_fn).status(200) - .header("content-type", content_type) - .body(content); - }); - (path.into(), mock) - }) - .collect() - - // let all_files1 = self.all_files.clone(); - // let all_files2 = self.all_files.clone(); - - // let mock = server.mock(move |when, then| { - // when.is_true(move |req| { - // matches!(req.method(), Method::HEAD | Method::GET) - // && all_files1.contains_key(&Self::get_request_file_path(req)) - // }); - // then.respond_with(move |req| { - // let path = Self::get_request_file_path(req); - // let body = all_files2[&path].clone(); - // let content_type = if path.ends_with(".json") { - // "application/json" - // } else { - // "text/plain" - // }; - // // If it was a HEAD request, the server will only send the headers automatically - // HttpMockResponse::builder() - // .status(200) - // .header("content-type", content_type) - // .body(body) - // .build() - // // if matches!(req.method(), Method::GET) { - // // let body = all_files2[&path].clone(); - // // let content_type = if path.ends_with(".json") { - // // "application/json" - // // } else { - // // "text/plain" - // // }; - // // response = response.header("content-type", content_type).body(body); - // // } - // // response.build() - // }); - // }); - // return mock; + pub fn add_zip_to_server() {} + + pub fn add_files_to_server<'a>( + &self, + server: &'a MockServer, + mut when_fn: impl FnMut(When) -> When, + mut then_fn: impl FnMut(Then) -> Then, + ) -> Mocks<'a> { + let mut mocks = Mocks { + head: HashMap::new(), + get: HashMap::new(), + }; + for (path, content) in self.all_files.iter() { + let content_type = if path.ends_with(".json") { + "application/json" + } else { + "text/plain" + }; + let mut when_fn = |when| when_fn(when).path(Self::path_to_url_path(path)); + let mut then_fn = |then| then_fn(then).header("content-type", content_type); + let head_mock = server.mock(|when, then| { + when_fn(when).method(HEAD); + then_fn(then).status(200); + }); + let get_mock = server.mock(|when, then| { + when_fn(when).method(GET); + then_fn(then).status(200).body(content); + }); + mocks.head.insert(path.clone(), head_mock); + mocks.get.insert(path.clone(), get_mock); + // (path.into(), mock) + } + mocks + // self.all_files + // .iter() + // .map(|(path, content)| { + // // for (path, content) in self.all_files.iter() { + // let mock = server.mock(|when, then| { + // // println!("{}", Self::path_to_url_path(path)); + // when_fn(when) + // .method(GET) + // // .method(HEAD) + // .path(Self::path_to_url_path(path)); + // let content_type = if path.ends_with(".json") { + // "application/json" + // } else { + // "text/plain" + // }; + // then_fn(then) + // .status(200) + // .header("content-type", content_type) + // .body(content); + // }); + // (path.into(), mock) + // }) + // .collect() + } + + // pub fn add_to_server<'a>( + // &self, + // server: &'a MockServer, + // mut when_fn: impl FnMut(When) -> When, + // mut then_fn: impl FnMut(Then) -> Then, + // ) -> HashMap> { + // self.all_files + // .iter() + // .map(|(path, content)| { + // // for (path, content) in self.all_files.iter() { + // let mock = server.mock(|when, then| { + // // println!("{}", Self::path_to_url_path(path)); + // when_fn(when) + // .method(GET) + // // .method(HEAD) + // .path(Self::path_to_url_path(path)); + // let content_type = if path.ends_with(".json") { + // "application/json" + // } else { + // "text/plain" + // }; + // then_fn(then) + // .status(200) + // .header("content-type", content_type) + // .body(content); + // }); + // (path.into(), mock) + // }) + // .collect() + // } +} + +pub fn mock_resolver<'a, I: IntoIterator>( + projects: I, +) -> MemoryResolver { + MemoryResolver { + iri_predicate: AcceptAll {}, + projects: HashMap::from_iter( + projects + .into_iter() + .map(|(k, v)| (Iri::parse(k.to_string()).unwrap(), vec![v])), + ), + } +} + +#[derive(Error, Debug)] +pub enum ProjectMockError { + #[error(".project.json is malformed: {0}")] + InfoMalformed(serde_json::Error), + #[error(".meta.json is malformed: {0}")] + MetaMalformed(serde_json::Error), + // #[error("{0}")] + // AlreadyExists(String), + #[error("project read error: file `{0}` not found")] + FileNotFound(Utf8PathBuf), + // #[error("failed to read from reader: {0}")] + // IoRead(#[from] std::io::Error), +} + +impl ProjectRead for ProjectMock { + type Error = ProjectMockError; + + fn get_project( + &self, + ) -> Result< + ( + Option, + Option, + ), + ProjectMockError, + > { + let info = self + .all_files + .get(&info_path()) + .map(|info| serde_json::from_str(info)) + .transpose(); + let meta = self + .all_files + .get(&metadata_path()) + .map(|meta| serde_json::from_str(meta)) + .transpose(); + match (info, meta) { + (Ok(info), Ok(meta)) => Ok((info, meta)), + (Err(info_err), _) => Err(ProjectMockError::InfoMalformed(info_err)), + (_, Err(meta_err)) => Err(ProjectMockError::MetaMalformed(meta_err)), + } + } + + type SourceReader<'a> = &'a [u8]; + + fn read_source>( + &self, + path: P, + ) -> Result, ProjectMockError> { + let path_buf = Utf8PathBuf::from(path.as_ref().as_str()); + let contents = self + .all_files + .get(&path_buf) + .ok_or(ProjectMockError::FileNotFound(path_buf))?; + + Ok(contents.as_bytes()) + } + + fn sources(&self, _ctx: &ProjectContext) -> Result, ProjectMockError> { + panic!("No sources for the ProjectMock are known") } } diff --git a/sysand/tests/cli_info.rs b/sysand/tests/cli_info.rs index 426f1da3..eaa483a6 100644 --- a/sysand/tests/cli_info.rs +++ b/sysand/tests/cli_info.rs @@ -15,7 +15,13 @@ use predicates::prelude::*; // pub due to https://github.com/rust-lang/rust/issues/46379 mod common; pub use common::*; -use sysand_core::test_utils::ProjectMock; +use sysand_core::test_utils::{ + Created, ProjectMock, ZipOptions, + httpmock::{ + Method::{GET, HEAD}, + MockServer, + }, +}; #[test] fn info_basic_in_cwd() -> Result<(), Box> { @@ -134,76 +140,53 @@ fn info_basic_iri_auto() -> Result<(), Box> { } #[test] -fn info_basic_http_url_noauth() -> Result<(), Box> { - let mut server = mockito::Server::new(); - - let git_mock = server - .mock("GET", "/info/refs?service=git-upload-pack") - .with_status(404) - .expect_at_most(2) // TODO: Reduce this to 1 - .create(); - - let kpar_range_probe = server - .mock("HEAD", "/") - .with_status(404) - .expect_at_most(1) - .create(); +fn info_basic_http_url_no_auth() -> Result<(), Box> { + let _ = env_logger::try_init(); + let project = ProjectMock::builder( + "info_basic_http_url", + "1.2.3", + Created::Custom("0000-00-00T00:00:00.123456789Z".into()), + ) + .build(); - let kpar_download_try = server - .mock("GET", "/") - .with_status(404) - .expect_at_most(1) - .create(); + let server = MockServer::start(); - let info_mock_head = server - .mock("HEAD", "/.project.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) - .expect_at_most(1) // TODO: Reduce this - .create(); + let git_mock = server.mock(|when, then| { + when.method(GET) + .path("/info/refs") + .query_param("service", "git-upload-pack"); + then.status(404); + }); - let info_mock = server - .mock("GET", "/.project.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) - .expect_at_most(3) // TODO: Reduce this to 1 - .create(); + let kpar_download_try = server.mock(|when, then| { + when.method(GET).path("/"); + then.status(404); + }); - let meta_mock_head = server - .mock("HEAD", "/.meta.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) - .expect_at_most(1) - .create(); + let kpar_range_probe = server.mock(|when, then| { + when.method(HEAD).path("/"); + then.status(404); + }); - let meta_mock = server - .mock("GET", "/.meta.json") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) - .expect_at_most(3) // TODO: Reduce this to 1 - .create(); + let mocks = project.add_files_to_server(&server, |when| when, |then| then); - let (_, _, out) = run_sysand(["info", "--iri", &server.url()], None)?; + let (_, _, out) = run_sysand(["info", "--iri", &server.base_url()], None)?; out.assert() .success() .stdout(predicate::str::contains("Name: info_basic_http_url")) .stdout(predicate::str::contains("Version: 1.2.3")); - git_mock.assert(); - - info_mock_head.assert(); - meta_mock_head.assert(); + git_mock.assert_calls(2); // TODO: Reduce this to 1 + kpar_range_probe.assert_calls(0); + kpar_download_try.assert_calls(1); - kpar_range_probe.assert(); - kpar_download_try.assert(); - - info_mock.assert(); - meta_mock.assert(); + for (_, head_mock) in mocks.head.iter() { + head_mock.assert_calls(1); // TODO: Reduce this + } + for (_, get_mock) in mocks.get.iter() { + get_mock.assert_calls(3); // TODO: Reduce this to 1 + } Ok(()) } @@ -658,22 +641,14 @@ fn info_basic_local_kpar() -> Result<(), Box> { let zip_path = cwd.path().canonicalize()?.join("test.kpar"); { - let project = ProjectMock::new_raw([ - ( - "some_root_dir/.project.json", - r#"{"name":"info_basic_local_kpar","version":"1.2.3","usage":[]}"#, - ), - ( - "some_root_dir/.meta.json", - r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#, - ), - ]); - - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Stored) - .unix_permissions(0o755); - - let zip_content = project.to_zip(options)?; + let project = ProjectMock::builder( + "info_basic_local_kpar", + "1.2.3", + Created::Custom("0000-00-00T00:00:00.123456789Z".into()), + ) + .build(); + + let zip_content = project.to_zip(ZipOptions::Default)?; fs::write(&zip_path, zip_content)?; } diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index f3c8081f..0d145c28 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -2,15 +2,14 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use assert_cmd::prelude::*; -use camino::Utf8PathBuf; use indexmap::IndexMap; use mockito::Matcher; use predicates::prelude::*; use reqwest::header; use sysand_core::{ - test_utils::{ProjectMock, httpmock::MockServer}, commands::lock::DEFAULT_LOCKFILE_NAME, env::local_directory::{DEFAULT_ENV_NAME, ENTRIES_PATH, METADATA_PATH}, + test_utils::{Created, ProjectMock, httpmock::MockServer, info_path, metadata_path}, }; // pub due to https://github.com/rust-lang/rust/issues/46379 @@ -163,11 +162,20 @@ identifiers = [ fn sync_to_remote() -> Result<(), Box> { let (_temp_dir, cwd) = new_temp_cwd()?; - let project_mock = ProjectMock::builder("sync_to_remote", "1.2.3").build(); + let project_mock = ProjectMock::builder( + "sync_to_remote", + "1.2.3", + Created::Custom("0000-00-00T00:00:00.123456789Z".into()), + ) + .build(); let server = MockServer::start(); - let mocks = project_mock.add_to_server(&server, |when| when.header_exists(header::USER_AGENT.as_str()), |then| then); + let mocks = project_mock.add_files_to_server( + &server, + |when| when.header_exists(header::USER_AGENT.as_str()), + |then| then, + ); std::fs::write( cwd.join(DEFAULT_LOCKFILE_NAME), @@ -184,7 +192,7 @@ sources = [ ] "#, &server.base_url() - ) + ), )?; let out = run_sysand_in(&cwd, ["sync"], None)?; @@ -195,8 +203,10 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); - mocks[&Utf8PathBuf::from("/.project.json")].assert_calls(4); // TODO: Reduce this to 1 after caching - mocks[&Utf8PathBuf::from("/.meta.json")].assert_calls(4); // TODO: Reduce this to 1 after caching + mocks.head[&info_path()].assert_calls(0); + mocks.head[&metadata_path()].assert_calls(0); + mocks.get[&info_path()].assert_calls(4); // TODO: Reduce this to 1 after caching + mocks.get[&metadata_path()].assert_calls(4); // TODO: Reduce this to 1 after caching let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?;