diff --git a/crates/pinakes-plugin-api/tests/example_plugin.rs b/crates/pinakes-plugin-api/tests/example_plugin.rs new file mode 100644 index 0000000..a9e94c8 --- /dev/null +++ b/crates/pinakes-plugin-api/tests/example_plugin.rs @@ -0,0 +1,61 @@ +//! Integration tests that parse and validate the media-stats-ui example. + +use pinakes_plugin_api::{PluginManifest, UiPage}; + +/// Resolve a path relative to the workspace root. +fn workspace_path(rel: &str) -> std::path::PathBuf { + // tests run from the crate root (crates/pinakes-plugin-api) + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(rel) +} + +#[test] +fn example_plugin_manifest_parses() { + let path = workspace_path("examples/plugins/media-stats-ui/plugin.toml"); + // load_ui_pages needs the manifest validated, but the file-based pages + // just need the paths to exist; we test them separately below. + let content = std::fs::read_to_string(&path).expect("read plugin.toml"); + // parse_str validates the manifest; it returns Err for any violation + let manifest = PluginManifest::parse_str(&content) + .expect("plugin.toml should parse and validate"); + assert_eq!(manifest.plugin.name, "media-stats-ui"); + assert_eq!( + manifest.ui.pages.len(), + 2, + "expected 2 page file references" + ); + assert_eq!(manifest.ui.widgets.len(), 1, "expected 1 widget"); +} + +#[test] +fn example_stats_page_parses_and_validates() { + let path = workspace_path("examples/plugins/media-stats-ui/pages/stats.json"); + let content = std::fs::read_to_string(&path).expect("read stats.json"); + let page: UiPage = + serde_json::from_str(&content).expect("stats.json should deserialise"); + assert_eq!(page.id, "stats"); + page.validate().expect("stats page should pass validation"); +} + +#[test] +fn example_tag_manager_page_parses_and_validates() { + let path = + workspace_path("examples/plugins/media-stats-ui/pages/tag-manager.json"); + let content = std::fs::read_to_string(&path).expect("read tag-manager.json"); + let page: UiPage = serde_json::from_str(&content) + .expect("tag-manager.json should deserialise"); + assert_eq!(page.id, "tag-manager"); + page + .validate() + .expect("tag-manager page should pass validation"); + // Verify the named action and data source are both present + assert!( + page.actions.contains_key("create-tag"), + "create-tag action should be defined" + ); + assert!( + page.data_sources.contains_key("tags"), + "tags data source should be defined" + ); +} diff --git a/crates/pinakes-plugin-api/tests/integration.rs b/crates/pinakes-plugin-api/tests/integration.rs new file mode 100644 index 0000000..a6d92fe --- /dev/null +++ b/crates/pinakes-plugin-api/tests/integration.rs @@ -0,0 +1,361 @@ +//! Integration tests for the plugin validation pipeline. +//! +//! Renderer-level behaviour (e.g., Dioxus components) is out of scope here; +//! that requires a Dioxus runtime and belongs in pinakes-ui tests. + +use std::collections::HashMap; + +use pinakes_plugin_api::{ + DataSource, + HttpMethod, + UiElement, + UiPage, + UiWidget, + validation::SchemaValidator, +}; + +// Build a minimal valid UiPage. +fn make_page(id: &str, route: &str) -> UiPage { + UiPage { + id: id.to_string(), + title: "Integration Test Page".to_string(), + route: route.to_string(), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + data_sources: HashMap::new(), + actions: HashMap::new(), + } +} + +// Build a minimal valid UiWidget. +fn make_widget(id: &str, target: &str) -> UiWidget { + UiWidget { + id: id.to_string(), + target: target.to_string(), + content: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + } +} + +// Build a complete valid PluginManifest as TOML string. +const fn valid_manifest_toml() -> &'static str { + r#" +[plugin] +name = "integration-test-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "stats" +title = "Statistics" +route = "/plugins/integration-test/stats" + +[ui.pages.layout] +type = "container" +children = [] +gap = 0 + +[[ui.widgets]] +id = "status-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "online" +"# +} + +// A complete valid manifest (with a page and a widget) passes all +// checks. +#[test] +fn test_full_valid_plugin_manifest_passes_all_checks() { + use pinakes_plugin_api::PluginManifest; + + let manifest = PluginManifest::parse_str(valid_manifest_toml()) + .expect("manifest must parse"); + + assert_eq!(manifest.plugin.name, "integration-test-plugin"); + assert_eq!(manifest.ui.pages.len(), 1); + assert_eq!(manifest.ui.widgets.len(), 1); + + // validate page via UiPage::validate + let page = make_page("my-plugin-page", "/plugins/integration-test/overview"); + page + .validate() + .expect("valid page must pass UiPage::validate"); + + // validate the same page via SchemaValidator + SchemaValidator::validate_page(&page) + .expect("valid page must pass SchemaValidator::validate_page"); + + // validate widget + let widget = make_widget("my-widget", "library_header"); + SchemaValidator::validate_widget(&widget) + .expect("valid widget must pass SchemaValidator::validate_widget"); +} + +// A page with a reserved route is rejected by both UiPage::validate +// and SchemaValidator::validate_page. +// +// The reserved routes exercised here are "/search" and "/admin". +#[test] +fn test_page_with_reserved_route_rejected() { + let reserved = ["/search", "/admin", "/settings", "/library", "/books"]; + + for route in reserved { + // UiPage::validate path + let page = make_page("my-page", route); + let result = page.validate(); + assert!( + result.is_err(), + "UiPage::validate must reject reserved route: {route}" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("conflicts with a built-in app route"), + "error for {route} must mention 'conflicts with a built-in app route', \ + got: {msg}" + ); + + // SchemaValidator::validate_page path + let page2 = make_page("my-page", route); + let result2 = SchemaValidator::validate_page(&page2); + assert!( + result2.is_err(), + "SchemaValidator::validate_page must reject reserved route: {route}" + ); + } +} + +// Sub-paths of reserved routes are also rejected. +#[test] +fn test_page_with_reserved_route_subpath_rejected() { + let subpaths = [ + "/search/advanced", + "/admin/users", + "/settings/theme", + "/library/foo", + ]; + for route in subpaths { + let page = make_page("my-page", route); + assert!( + page.validate().is_err(), + "reserved route sub-path must be rejected: {route}" + ); + } +} + +// A UiWidget whose content contains a DataTable fails validation. +// +// Widgets have no data-fetching mechanism; any data source reference in a +// widget content tree must be caught at load time. +#[test] +fn test_widget_with_datatable_fails_validation() { + use pinakes_plugin_api::ColumnDef; + + let col: ColumnDef = + serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) + .unwrap(); + + let widget = UiWidget { + id: "bad-widget".to_string(), + target: "library_header".to_string(), + content: UiElement::DataTable { + data: "items".to_string(), + columns: vec![col], + sortable: false, + filterable: false, + page_size: 0, + row_actions: vec![], + }, + }; + + let result = SchemaValidator::validate_widget(&widget); + assert!( + result.is_err(), + "widget with DataTable content must fail validation" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("cannot reference data sources"), + "error must mention data sources, got: {msg}" + ); +} + +// A UiWidget whose content is a Container wrapping a Loop fails +// validation. Basically a recursive data source check. +#[test] +fn test_widget_container_wrapping_loop_fails_validation() { + use pinakes_plugin_api::Expression; + + let widget = UiWidget { + id: "loop-widget".to_string(), + target: "library_header".to_string(), + content: UiElement::Container { + children: vec![UiElement::Loop { + data: "items".to_string(), + template: Box::new(UiElement::Text { + content: Default::default(), + variant: Default::default(), + allow_html: false, + }), + empty: None, + }], + gap: 0, + padding: None, + }, + }; + + let result = SchemaValidator::validate_widget(&widget); + assert!( + result.is_err(), + "widget containing a Loop must fail validation" + ); + let _ = Expression::default(); // Expression should be accessible from the import path +} + +// Endpoint data source with a path that does not start with /api/ fails +// UiPage::validate (DataSource::validate is called there). +#[test] +fn test_endpoint_data_source_non_api_path_rejected() { + let mut page = make_page("my-page", "/plugins/test/page"); + page + .data_sources + .insert("items".to_string(), DataSource::Endpoint { + path: "/v1/media".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: None, + }); + + // DataSource::validate requires /api/ prefix + assert!( + page.validate().is_err(), + "data source path not starting with /api/ must be rejected" + ); + + // Also verify SchemaValidator::validate_page rejects it + let result2 = SchemaValidator::validate_page(&page); + assert!( + result2.is_err(), + "SchemaValidator should reject non-/api/ endpoint path" + ); +} + +// A static data source with any value passes validation on its own. +#[test] +fn test_static_data_source_passes_validation() { + use pinakes_plugin_api::ColumnDef; + + let col: ColumnDef = + serde_json::from_value(serde_json::json!({"key": "n", "header": "N"})) + .unwrap(); + + let mut page = make_page("my-page", "/plugins/test/page"); + page + .data_sources + .insert("nums".to_string(), DataSource::Static { + value: serde_json::json!([1, 2, 3]), + }); + + // Root element references the static data source so DataTable passes + page.root_element = UiElement::DataTable { + data: "nums".to_string(), + columns: vec![col], + sortable: false, + filterable: false, + page_size: 0, + row_actions: vec![], + }; + page + .validate() + .expect("page with static data source must pass validation"); +} + +// Parse TOML string, validate, and load inline pages round-trips without +// errors. +#[test] +fn test_manifest_inline_page_roundtrip() { + use pinakes_plugin_api::{PluginManifest, manifest::UiPageEntry}; + + let toml = r#" +[plugin] +name = "roundtrip-plugin" +version = "2.3.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "overview" +title = "Overview" +route = "/plugins/roundtrip/overview" + +[ui.pages.layout] +type = "container" +children = [] +gap = 8 +"#; + + let manifest = PluginManifest::parse_str(toml).expect("manifest must parse"); + assert_eq!(manifest.plugin.name, "roundtrip-plugin"); + assert_eq!(manifest.ui.pages.len(), 1); + + match &manifest.ui.pages[0] { + UiPageEntry::Inline(page) => { + assert_eq!(page.id, "overview"); + assert_eq!(page.route, "/plugins/roundtrip/overview"); + // UiPage::validate must also succeed for inline pages + page + .validate() + .expect("inline page must pass UiPage::validate"); + }, + UiPageEntry::File { .. } => { + panic!("expected inline page entry, got file reference"); + }, + } +} + +// warnings by PluginManifest::validate but do NOT cause an error return. +// (The UiSection::validate failure is non-fatal; see manifest.rs.) +#[test] +fn test_manifest_bad_required_endpoint_is_non_fatal() { + use pinakes_plugin_api::PluginManifest; + + let toml = r#" +[plugin] +name = "ep-test" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[ui] +required_endpoints = ["/not-an-api-path"] +"#; + + // PluginManifest::validate emits a tracing::warn for invalid + // required_endpoints but does not return Err - verify the manifest still + // parses successfully. + let result = PluginManifest::parse_str(toml); + assert!( + result.is_ok(), + "bad required_endpoint should be non-fatal for PluginManifest::validate" + ); +}