diff --git a/.buildkite/setup-test-site.sh b/.buildkite/setup-test-site.sh index d2cb32c68..fbcb0e926 100755 --- a/.buildkite/setup-test-site.sh +++ b/.buildkite/setup-test-site.sh @@ -42,3 +42,7 @@ wp plugin delete wordpress-importer ## Create an Application password for a subscriber user, and store it where it can be used by the test suite wp user application-password create themedemos test --porcelain } >> /tmp/test_credentials + +## Used for integration tests +wp plugin install hello-dolly --activate +wp plugin install classic-editor diff --git a/Makefile b/Makefile index 02e9f71c6..87e3460f2 100644 --- a/Makefile +++ b/Makefile @@ -192,7 +192,7 @@ test-server: stop-server docker-compose up -d docker-compose run wpcli -stop-server: +stop-server: delete-wp-plugins-backup docker-compose down dump-mysql: @@ -201,6 +201,15 @@ dump-mysql: restore-mysql: docker exec -it wordpress-rs-mysql-1 /bin/bash -c "mysql --defaults-extra-file=mysql_config/config.cnf --database wordpress < dump.sql" +backup-wp-content-plugins: + docker exec -it wordpress /bin/bash -c "cp -R ./wp-content/plugins /tmp/backup_wp_plugins" + +restore-wp-content-plugins: + docker exec -it wordpress /bin/bash -c "rm -rf ./wp-content/plugins && cp -R /tmp/backup_wp_plugins ./wp-content/plugins" + +delete-wp-plugins-backup: + docker exec -it wordpress /bin/bash -c "rm -rf /tmp/backup_wp_plugins" || true + lint: lint-rust lint-swift lint-rust: diff --git a/wp_api/src/endpoint.rs b/wp_api/src/endpoint.rs index 71b52545a..abccdc1d1 100644 --- a/wp_api/src/endpoint.rs +++ b/wp_api/src/endpoint.rs @@ -1,7 +1,9 @@ use url::Url; +pub use plugins_endpoint::*; pub use users_endpoint::*; +mod plugins_endpoint; mod users_endpoint; const WP_JSON_PATH_SEGMENTS: [&str; 3] = ["wp-json", "wp", "v2"]; @@ -47,6 +49,7 @@ impl ApiBaseUrl { pub struct ApiEndpoint { pub base_url: ApiBaseUrl, pub users: UsersEndpoint, + pub plugins: PluginsEndpoint, } impl ApiEndpoint { @@ -54,6 +57,7 @@ impl ApiEndpoint { Self { base_url: api_base_url.clone(), users: UsersEndpoint::new(api_base_url.clone()), + plugins: PluginsEndpoint::new(api_base_url.clone()), } } diff --git a/wp_api/src/endpoint/plugins_endpoint.rs b/wp_api/src/endpoint/plugins_endpoint.rs new file mode 100644 index 000000000..015d46e85 --- /dev/null +++ b/wp_api/src/endpoint/plugins_endpoint.rs @@ -0,0 +1,146 @@ +use url::Url; + +use crate::{plugins::PluginListParams, ApiBaseUrl, WPContext}; + +pub struct PluginsEndpoint { + api_base_url: ApiBaseUrl, +} + +impl PluginsEndpoint { + pub fn new(api_base_url: ApiBaseUrl) -> Self { + Self { api_base_url } + } + + pub fn create(&self) -> Url { + self.plugins_base_url() + } + + pub fn delete(&self, plugin: &str) -> Url { + self.plugins_url_with_slug(plugin) + } + + pub fn list(&self, context: WPContext, params: Option<&PluginListParams>) -> Url { + let mut url = self.plugins_base_url(); + url.query_pairs_mut() + .append_pair("context", context.as_str()); + if let Some(params) = params { + url.query_pairs_mut().extend_pairs(params.query_pairs()); + } + url + } + + pub fn retrieve(&self, context: WPContext, plugin: &str) -> Url { + let mut url = self.plugins_url_with_slug(plugin); + url.query_pairs_mut() + .append_pair("context", context.as_str()); + url + } + + pub fn update(&self, plugin: &str) -> Url { + self.plugins_url_with_slug(plugin) + } + + fn plugins_base_url(&self) -> Url { + self.api_base_url.by_appending("plugins") + } + + fn plugins_url_with_slug(&self, plugin: &str) -> Url { + self.api_base_url + // The '/' character has to be preserved and not get encoded + .by_extending(["plugins"].into_iter().chain(plugin.split('/'))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + endpoint::tests::{fixture_api_base_url, validate_endpoint}, + generate, ApiEndpoint, PluginStatus, + }; + use rstest::*; + + #[rstest] + fn create_plugin(plugins_endpoint: PluginsEndpoint) { + validate_endpoint(plugins_endpoint.create(), "/plugins"); + } + + #[rstest] + #[case("hello-dolly/hello", "/plugins/hello-dolly/hello")] + #[case( + "classic-editor/classic-editor", + "/plugins/classic-editor/classic-editor" + )] + #[case("foo/bar%baz", "/plugins/foo/bar%25baz")] + #[case("foo/です", "/plugins/foo/%E3%81%A7%E3%81%99")] + fn delete_plugin( + plugins_endpoint: PluginsEndpoint, + #[case] plugin_slug: &str, + #[case] expected_path: &str, + ) { + validate_endpoint(plugins_endpoint.delete(plugin_slug), expected_path); + } + + #[rstest] + #[case(WPContext::Edit, generate!(PluginListParams, (search, Some("foo".to_string()))), "/plugins?context=edit&search=foo")] + #[case(WPContext::Embed, generate!(PluginListParams, (status, Some(PluginStatus::Active))), "/plugins?context=embed&status=active")] + #[case(WPContext::View, generate!(PluginListParams, (search, Some("foo".to_string())), (status, Some(PluginStatus::Inactive))), "/plugins?context=view&search=foo&status=inactive")] + fn list_plugins_with_params( + plugins_endpoint: PluginsEndpoint, + #[case] context: WPContext, + #[case] params: PluginListParams, + #[case] expected_path: &str, + ) { + validate_endpoint(plugins_endpoint.list(context, Some(¶ms)), expected_path); + } + + #[rstest] + #[case( + "hello-dolly/hello", + WPContext::View, + "/plugins/hello-dolly/hello?context=view" + )] + #[case( + "classic-editor/classic-editor", + WPContext::Embed, + "/plugins/classic-editor/classic-editor?context=embed" + )] + #[case("foo/bar%baz", WPContext::Edit, "/plugins/foo/bar%25baz?context=edit")] + #[case( + "foo/です", + WPContext::View, + "/plugins/foo/%E3%81%A7%E3%81%99?context=view" + )] + fn retrieve_plugin( + plugins_endpoint: PluginsEndpoint, + #[case] plugin_slug: &str, + #[case] context: WPContext, + #[case] expected_path: &str, + ) { + validate_endpoint( + plugins_endpoint.retrieve(context, plugin_slug), + expected_path, + ); + } + + #[rstest] + #[case("hello-dolly/hello", "/plugins/hello-dolly/hello")] + #[case( + "classic-editor/classic-editor", + "/plugins/classic-editor/classic-editor" + )] + #[case("foo/bar%baz", "/plugins/foo/bar%25baz")] + #[case("foo/です", "/plugins/foo/%E3%81%A7%E3%81%99")] + fn update_plugin( + plugins_endpoint: PluginsEndpoint, + #[case] plugin_slug: &str, + #[case] expected_path: &str, + ) { + validate_endpoint(plugins_endpoint.update(plugin_slug), expected_path); + } + + #[fixture] + fn plugins_endpoint(fixture_api_base_url: ApiBaseUrl) -> PluginsEndpoint { + ApiEndpoint::new(fixture_api_base_url).plugins + } +} diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index eef523548..f1a9e8734 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -7,6 +7,7 @@ pub use api_error::*; pub use endpoint::*; pub use login::*; pub use pages::*; +pub use plugins::*; pub use posts::*; pub use url::*; pub use users::*; @@ -15,6 +16,7 @@ pub mod api_error; pub mod endpoint; pub mod login; pub mod pages; +pub mod plugins; pub mod posts; pub mod url; pub mod users; @@ -223,6 +225,63 @@ impl WPApiHelper { } } + pub fn list_plugins_request( + &self, + context: WPContext, + params: &Option, // UniFFI doesn't support Option<&T> + ) -> WPNetworkRequest { + WPNetworkRequest { + method: RequestMethod::GET, + url: self + .api_endpoint + .plugins + .list(context, params.as_ref()) + .into(), + header_map: self.header_map(), + body: None, + } + } + + pub fn create_plugin_request(&self, params: &PluginCreateParams) -> WPNetworkRequest { + WPNetworkRequest { + method: RequestMethod::POST, + url: self.api_endpoint.plugins.create().into(), + header_map: self.header_map_for_post_request(), + body: serde_json::to_vec(¶ms).ok(), + } + } + + pub fn retrieve_plugin_request(&self, context: WPContext, plugin: &str) -> WPNetworkRequest { + WPNetworkRequest { + method: RequestMethod::GET, + url: self.api_endpoint.plugins.retrieve(context, plugin).into(), + header_map: self.header_map(), + body: None, + } + } + + pub fn update_plugin_request( + &self, + plugin: &str, + params: PluginUpdateParams, + ) -> WPNetworkRequest { + WPNetworkRequest { + method: RequestMethod::POST, + url: self.api_endpoint.plugins.update(plugin).into(), + header_map: self.header_map_for_post_request(), + body: serde_json::to_vec(¶ms).ok(), + } + } + + pub fn delete_plugin_request(&self, plugin: &str) -> WPNetworkRequest { + WPNetworkRequest { + method: RequestMethod::DELETE, + url: self.api_endpoint.plugins.delete(plugin).into(), + header_map: self.header_map(), + body: None, + } + } + fn header_map(&self) -> HashMap { let mut header_map = HashMap::new(); header_map.insert( diff --git a/wp_api/src/plugins.rs b/wp_api/src/plugins.rs new file mode 100644 index 000000000..707788e64 --- /dev/null +++ b/wp_api/src/plugins.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; +use wp_derive::WPContextual; + +use crate::{add_uniffi_exported_parser, parse_wp_response, WPApiError, WPNetworkResponse}; + +add_uniffi_exported_parser!( + parse_list_plugins_response_with_edit_context, + Vec +); +add_uniffi_exported_parser!( + parse_list_plugins_response_with_embed_context, + Vec +); +add_uniffi_exported_parser!( + parse_list_plugins_response_with_view_context, + Vec +); +add_uniffi_exported_parser!( + parse_retrieve_plugin_response_with_edit_context, + PluginWithEditContext +); +add_uniffi_exported_parser!( + parse_retrieve_plugin_response_with_embed_context, + PluginWithEmbedContext +); +add_uniffi_exported_parser!( + parse_retrieve_plugin_response_with_view_context, + PluginWithViewContext +); +add_uniffi_exported_parser!(parse_create_plugin_response, PluginWithEditContext); +add_uniffi_exported_parser!(parse_update_plugin_response, PluginWithEditContext); +add_uniffi_exported_parser!(parse_delete_plugin_response, PluginDeleteResponse); + +#[derive(Default, Debug, uniffi::Record)] +pub struct PluginListParams { + /// Limit results to those matching a string. + pub search: Option, + /// Limits results to plugins with the given status. + pub status: Option, +} + +impl PluginListParams { + pub fn query_pairs(&self) -> impl IntoIterator { + [ + ("search", self.search.clone()), + ("status", self.status.map(|x| x.as_str().to_string())), + ] + .into_iter() + // Remove `None` values + .filter_map(|(k, opt_v)| opt_v.map(|v| (k, v))) + } +} + +#[derive(Debug, Serialize, uniffi::Record)] +pub struct PluginCreateParams { + /// WordPress.org plugin directory slug. + pub slug: String, + /// The plugin activation status. + pub status: PluginStatus, +} + +#[derive(Debug, Serialize, uniffi::Record)] +pub struct PluginUpdateParams { + /// The plugin activation status. + pub status: PluginStatus, + // According to the documentation: https://developer.wordpress.org/rest-api/reference/plugins/#update-a-plugin + // There is supposed to be a `context` parameter as well, but this parameter doesn't seem to + // modify the response fields as promised in the documentation. + // In order to avoid confusion, this parameter is not included in this implementation. +} + +#[derive(Debug, Serialize, Deserialize, uniffi::Record, WPContextual)] +pub struct SparsePlugin { + #[WPContext(edit, embed, view)] + pub plugin: Option, + #[WPContext(edit, embed, view)] + pub status: Option, + #[WPContext(edit, embed, view)] + pub name: Option, + #[WPContext(edit, view)] + pub plugin_uri: Option, + #[WPContext(edit, view)] + pub author: Option, + #[WPContext(edit, view)] + pub description: Option, + #[WPContext(edit, view)] + pub version: Option, + #[WPContext(edit, embed, view)] + pub network_only: Option, + #[WPContext(edit, embed, view)] + pub requires_php: Option, + #[WPContext(edit, view)] + pub textdomain: Option, +} + +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct PluginDeleteResponse { + pub deleted: bool, + pub previous: PluginWithEditContext, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, uniffi::Enum)] +pub enum PluginStatus { + #[serde(rename = "active")] + Active, + #[serde(rename = "inactive")] + Inactive, +} + +impl PluginStatus { + fn as_str(&self) -> &str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + } + } +} + +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct PluginDescription { + pub raw: String, + pub rendered: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{generate, test_helpers::assert_expected_query_pairs}; + use rstest::*; + + #[rstest] + #[case(PluginListParams::default(), &[])] + #[case(generate!(PluginListParams, (search, Some("foo".to_string()))), &[("search", "foo")])] + #[case(generate!(PluginListParams, (status, Some(PluginStatus::Active))), &[("status", "active")])] + #[case(generate!(PluginListParams, (search, Some("foo".to_string())), (status, Some(PluginStatus::Inactive))), &[("search", "foo"), ("status", "inactive")])] + #[trace] + fn test_plugin_list_params( + #[case] params: PluginListParams, + #[case] expected_pairs: &[(&str, &str)], + ) { + assert_expected_query_pairs(params.query_pairs(), expected_pairs); + } +} diff --git a/wp_api/src/users.rs b/wp_api/src/users.rs index 8b9043511..ac8874498 100644 --- a/wp_api/src/users.rs +++ b/wp_api/src/users.rs @@ -183,7 +183,7 @@ impl UserListParams { } } -#[derive(Serialize, uniffi::Record)] +#[derive(Serialize, Debug, uniffi::Record)] pub struct UserCreateParams { /// Login name for the user. pub username: String, diff --git a/wp_networking/tests/test_helpers.rs b/wp_networking/tests/test_helpers.rs index 6a9dd2874..b2f9847b8 100644 --- a/wp_networking/tests/test_helpers.rs +++ b/wp_networking/tests/test_helpers.rs @@ -1,4 +1,5 @@ -use std::fs::read_to_string; +use futures::Future; +use std::{fs::read_to_string, process::Command}; use wp_api::{ UserId, WPApiError, WPApiHelper, WPAuthentication, WPNetworkRequest, WPNetworkResponse, WPRestError, WPRestErrorCode, WPRestErrorWrapper, @@ -11,6 +12,9 @@ pub const FIRST_USER_ID: UserId = UserId(1); pub const SECOND_USER_ID: UserId = UserId(2); pub const SECOND_USER_EMAIL: &str = "themeshaperwp+demos@gmail.com"; pub const SECOND_USER_SLUG: &str = "themedemos"; +pub const HELLO_DOLLY_PLUGIN_SLUG: &str = "hello-dolly/hello"; +pub const CLASSIC_EDITOR_PLUGIN_SLUG: &str = "classic-editor/classic-editor"; +pub const WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS: &str = "classic-widgets"; pub fn api() -> WPApiHelper { let credentials = read_test_credentials_from_file(); @@ -147,3 +151,18 @@ fn expected_status_code_for_wp_rest_error_code(error_code: &WPRestErrorCode) -> WPRestErrorCode::UserInvalidUsername => 400, } } + +pub async fn run_and_restore_wp_content_plugins(f: F) +where + F: FnOnce() -> Fut, + Fut: Future, +{ + f().await; + println!("Restoring wp-content/plugins.."); + Command::new("make") + .arg("-C") + .arg("../") + .arg("restore-wp-content-plugins") + .status() + .expect("Failed to restore wp-content/plugins"); +} diff --git a/wp_networking/tests/test_plugins_immut.rs b/wp_networking/tests/test_plugins_immut.rs new file mode 100644 index 000000000..e99ce147a --- /dev/null +++ b/wp_networking/tests/test_plugins_immut.rs @@ -0,0 +1,79 @@ +use rstest::*; +use wp_api::{generate, plugins::PluginListParams, plugins::PluginStatus, WPContext}; + +use crate::test_helpers::{ + api, WPNetworkRequestExecutor, WPNetworkResponseParser, CLASSIC_EDITOR_PLUGIN_SLUG, + HELLO_DOLLY_PLUGIN_SLUG, +}; + +pub mod test_helpers; + +#[rstest] +#[case(PluginListParams::default())] +#[case(generate!(PluginListParams, (search, Some("foo".to_string()))))] +#[case(generate!(PluginListParams, (status, Some(PluginStatus::Active))))] +#[case(generate!(PluginListParams, (search, Some("foo".to_string())), (status, Some(PluginStatus::Inactive))))] +#[trace] +#[tokio::test] +async fn test_plugin_list_params_parametrized( + #[case] params: PluginListParams, + #[values(WPContext::Edit, WPContext::Embed, WPContext::View)] context: WPContext, +) { + let response = api() + .list_plugins_request(context, &Some(params)) + .execute() + .await + .unwrap(); + match context { + WPContext::Edit => { + let parsed_response = wp_api::parse_list_plugins_response_with_edit_context(&response); + assert!( + parsed_response.is_ok(), + "Response was: '{:?}'", + parsed_response + ); + } + WPContext::Embed => { + let parsed_response = wp_api::parse_list_plugins_response_with_embed_context(&response); + assert!( + parsed_response.is_ok(), + "Response was: '{:?}'", + parsed_response + ); + } + WPContext::View => { + let parsed_response = wp_api::parse_list_plugins_response_with_view_context(&response); + assert!( + parsed_response.is_ok(), + "Response was: '{:?}'", + parsed_response + ); + } + }; +} + +#[rstest] +#[case(CLASSIC_EDITOR_PLUGIN_SLUG, "WordPress Contributors")] +#[case(HELLO_DOLLY_PLUGIN_SLUG, "Matt Mullenweg")] +#[trace] +#[tokio::test] +async fn retrieve_plugin( + #[case] plugin_slug: &str, + #[case] expected_author: &str, + #[values(WPContext::Edit, WPContext::Embed, WPContext::View)] context: WPContext, +) { + let parsed_response = api() + .retrieve_plugin_request(context, plugin_slug) + .execute() + .await + .unwrap() + .parse(wp_api::parse_retrieve_plugin_response_with_edit_context); + assert!( + parsed_response.is_ok(), + "Retrieve plugin failed!\nContext: {:?}\nPlugin: {}\nResponse was: '{:?}'", + context, + plugin_slug, + parsed_response + ); + assert_eq!(expected_author, parsed_response.unwrap().author); +} diff --git a/wp_networking/tests/test_plugins_mut.rs b/wp_networking/tests/test_plugins_mut.rs new file mode 100644 index 000000000..7a3455835 --- /dev/null +++ b/wp_networking/tests/test_plugins_mut.rs @@ -0,0 +1,66 @@ +use wp_api::{PluginCreateParams, PluginStatus, PluginUpdateParams}; + +use crate::test_helpers::{ + api, run_and_restore_wp_content_plugins, WPNetworkRequestExecutor, WPNetworkResponseParser, + CLASSIC_EDITOR_PLUGIN_SLUG, HELLO_DOLLY_PLUGIN_SLUG, WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS, +}; + +pub mod test_helpers; +pub mod wp_db; + +#[tokio::test] +async fn create_plugin() { + run_and_restore_wp_content_plugins(|| { + wp_db::run_and_restore(|mut _db| async move { + let status = PluginStatus::Active; + let params = PluginCreateParams { + slug: WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS.to_string(), + status, + }; + let created_plugin = api() + .create_plugin_request(¶ms) + .execute() + .await + .unwrap() + .parse(wp_api::parse_create_plugin_response) + .unwrap(); + println!("Created Plugin: {:?}", created_plugin); + }) + }) + .await; +} + +#[tokio::test] +async fn update_plugin() { + run_and_restore_wp_content_plugins(|| { + wp_db::run_and_restore(|mut _db| async move { + let status = PluginStatus::Active; + let updated_plugin = api() + .update_plugin_request(HELLO_DOLLY_PLUGIN_SLUG, PluginUpdateParams { status }) + .execute() + .await + .unwrap() + .parse(wp_api::parse_update_plugin_response) + .unwrap(); + println!("Updated Plugin: {:?}", updated_plugin); + }) + }) + .await; +} + +#[tokio::test] +async fn delete_plugin() { + run_and_restore_wp_content_plugins(|| { + wp_db::run_and_restore(|mut _db| async move { + let deleted_plugin = api() + .delete_plugin_request(CLASSIC_EDITOR_PLUGIN_SLUG) + .execute() + .await + .unwrap() + .parse(wp_api::parse_delete_plugin_response) + .unwrap(); + println!("Deleted Plugin: {:?}", deleted_plugin); + }) + }) + .await; +}