From 9cc78a5c25a694b7f0ad8361e267e7f4c561371c Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Mon, 17 Aug 2026 23:08:32 -0500 Subject: [PATCH 1/2] feat: Add PHPUnit unit + integration test suites to all sub-plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds real test coverage (148 tests total) to community-listings, contentful-tables, and graphql-shortcode-support, none of which had any automated tests before. Each sub-plugin gets its own phpunit.xml and tests/Unit + tests/Integration suites, extending wp-plugin-kernel's Testing\TestCase; WPGraphQL integration tests are guarded with class_exists('WPGraphQL') + markTestSkipped(). Two new repo-root scripts set up one shared WordPress test environment for all three sub-plugins: install-wp-tests.sh (thin wrapper around the vendored wp-coding-standards installer) and install-wpgraphql-for-tests.sh (adapted from silver-assist-security). Writing the database-fallback test for TableDataLoader surfaced a real bug: load_tables_from_database() selected a table_id column that has never existed in the schema (Activator creates entry_id) — fixed alongside the new tests. This is PR B2 of the contentful-wordpress-plugins standardization effort. --- .gitignore | 6 +- CHANGELOG.md | 3 + CONTRIBUTING.md | 10 +- README.md | 12 + community-listings/README.md | 8 + community-listings/composer.json | 19 +- community-listings/phpcs.xml | 1 + community-listings/phpunit.xml | 42 +++ .../Integration/CptRegistrarGraphQLTest.php | 61 +++++ .../tests/Integration/GraphQLResolverTest.php | 83 ++++++ .../tests/Unit/ActivatorTest.php | 28 ++ .../tests/Unit/CptRegistrarTest.php | 86 ++++++ community-listings/tests/Unit/PluginTest.php | 48 ++++ .../Unit/ProviderListingsMetaBoxTest.php | 156 +++++++++++ .../tests/Unit/RestApiFiltersTest.php | 93 +++++++ .../tests/Unit/SettingsPageTest.php | 64 +++++ community-listings/tests/bootstrap.php | 47 ++++ contentful-tables/README.md | 9 +- contentful-tables/composer.json | 19 +- .../includes/Service/TableDataLoader.php | 4 +- contentful-tables/phpcs.xml | 1 + contentful-tables/phpunit.xml | 42 +++ .../tests/Integration/GraphQLResolverTest.php | 83 ++++++ .../tests/Integration/TableDataLoaderTest.php | 231 ++++++++++++++++ .../tests/Unit/ActivatorTest.php | 59 +++++ .../tests/Unit/CardsRendererTest.php | 107 ++++++++ .../tests/Unit/ChartRendererTest.php | 70 +++++ .../tests/Unit/CsvParserTest.php | 74 ++++++ .../tests/Unit/FormRendererTest.php | 41 +++ contentful-tables/tests/Unit/HelpersTest.php | 122 +++++++++ contentful-tables/tests/Unit/PluginTest.php | 38 +++ .../tests/Unit/SettingsPageTest.php | 71 +++++ .../tests/Unit/ShortcodeRegistrarTest.php | 121 +++++++++ .../tests/Unit/TableRendererTest.php | 89 +++++++ .../tests/Unit/TocRendererTest.php | 60 +++++ contentful-tables/tests/bootstrap.php | 47 ++++ graphql-shortcode-support/README.md | 21 +- graphql-shortcode-support/phpunit.xml | 42 +++ .../GraphQLShortcodeResolverTest.php | 108 ++++++++ .../tests/Unit/ActivatorTest.php | 46 ++++ .../tests/Unit/SettingsPageTest.php | 77 ++++++ graphql-shortcode-support/tests/bootstrap.php | 47 ++++ scripts/install-wp-tests.sh | 24 ++ scripts/install-wpgraphql-for-tests.sh | 247 ++++++++++++++++++ 44 files changed, 2647 insertions(+), 20 deletions(-) create mode 100644 community-listings/phpunit.xml create mode 100644 community-listings/tests/Integration/CptRegistrarGraphQLTest.php create mode 100644 community-listings/tests/Integration/GraphQLResolverTest.php create mode 100644 community-listings/tests/Unit/ActivatorTest.php create mode 100644 community-listings/tests/Unit/CptRegistrarTest.php create mode 100644 community-listings/tests/Unit/PluginTest.php create mode 100644 community-listings/tests/Unit/ProviderListingsMetaBoxTest.php create mode 100644 community-listings/tests/Unit/RestApiFiltersTest.php create mode 100644 community-listings/tests/Unit/SettingsPageTest.php create mode 100644 community-listings/tests/bootstrap.php create mode 100644 contentful-tables/phpunit.xml create mode 100644 contentful-tables/tests/Integration/GraphQLResolverTest.php create mode 100644 contentful-tables/tests/Integration/TableDataLoaderTest.php create mode 100644 contentful-tables/tests/Unit/ActivatorTest.php create mode 100644 contentful-tables/tests/Unit/CardsRendererTest.php create mode 100644 contentful-tables/tests/Unit/ChartRendererTest.php create mode 100644 contentful-tables/tests/Unit/CsvParserTest.php create mode 100644 contentful-tables/tests/Unit/FormRendererTest.php create mode 100644 contentful-tables/tests/Unit/HelpersTest.php create mode 100644 contentful-tables/tests/Unit/PluginTest.php create mode 100644 contentful-tables/tests/Unit/SettingsPageTest.php create mode 100644 contentful-tables/tests/Unit/ShortcodeRegistrarTest.php create mode 100644 contentful-tables/tests/Unit/TableRendererTest.php create mode 100644 contentful-tables/tests/Unit/TocRendererTest.php create mode 100644 contentful-tables/tests/bootstrap.php create mode 100644 graphql-shortcode-support/phpunit.xml create mode 100644 graphql-shortcode-support/tests/Integration/GraphQLShortcodeResolverTest.php create mode 100644 graphql-shortcode-support/tests/Unit/ActivatorTest.php create mode 100644 graphql-shortcode-support/tests/Unit/SettingsPageTest.php create mode 100644 graphql-shortcode-support/tests/bootstrap.php create mode 100755 scripts/install-wp-tests.sh create mode 100755 scripts/install-wpgraphql-for-tests.sh diff --git a/.gitignore b/.gitignore index 2a2b9f4..7d956f7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,10 +46,10 @@ composer.phar composer.lock # PHPUnit -/phpunit.xml /phpunit.xml.dist -/.phpunit.result.cache -/coverage/ +**/.phpunit.result.cache +**/tests/coverage/ +**/tests/results/ # PHP CS Fixer .php_cs.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index f980c98..69eb6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,15 @@ Individual plugin versions are tracked separately in their respective plugin hea ### Added - **community-listings:** Admin meta box ("Provider Listings") on the `community` post type editor, city-level posts only — lets editors view/update the `provider_listings` JSON without CLI access. JSON-safe save via `$wpdb->update()`/`insert()` (avoids `update_post_meta()`'s `wp_unslash()` corrupting `\"` escapes), with byte/provider counts, client-side validation, and post-meta cache invalidation after the direct write (#2) +- Real PHPUnit test suites (unit + WPGraphQL integration) for all three sub-plugins — 148 tests total (community-listings 38, contentful-tables 92, graphql-shortcode-support 18), none of which had any automated tests before. Each sub-plugin gets its own `phpunit.xml`/`tests/Unit`/`tests/Integration`, extending `silverassist/wp-plugin-kernel`'s `Testing\TestCase` (already available via the existing runtime dependency). Two new repo-root scripts (`scripts/install-wp-tests.sh`, a thin wrapper around the vendored `silverassist/wp-coding-standards` installer; `scripts/install-wpgraphql-for-tests.sh`, adapted from `silver-assist-security`) set up one shared WP test environment for all three sub-plugins to run against ### Changed - Adopted the `SilverAssistWP` PHPCS ruleset and composed shared `silverassist/coding-standards` + `silverassist/wp-coding-standards` PHPStan base configs across all three sub-plugins, replacing hand-rolled rulesets and duplicated static-analysis baselines (#3) +- **community-listings, contentful-tables:** `phpcs.xml` now excludes `tests/*`, matching `graphql-shortcode-support`'s existing convention — test files intentionally aren't held to the same doc-comment-per-method WPCS rules as plugin source ### Fixed - **community-listings:** Resolved all 3 pre-existing PHPStan errors — dead-code truthy check in `SettingsPage::render_settings_page()`, a `register_graphql_field()` call missing its own `function_exists()` guard, and an unreachable array-offset fallback in the GraphQL type-mapping refactored to a `match` expression +- **contentful-tables:** `TableDataLoader::load_tables_from_database()` selected a non-existent `table_id` column — the table created by `Activator` has always used `entry_id`. Writing the new database-fallback test surfaced this immediately; the fallback path itself was never previously reachable by any test, so this SQL error would have surfaced only in production the first time a site had no matching table files or post meta - **Makefile:** `phpcs`, `phpstan`, `install`, `install-dev`, and `build` targets no longer silently ignore failures in any sub-plugin but the last one in `$(PLUGINS)` — each `for` loop now propagates a non-zero exit status, so CI actually fails when any sub-plugin has a real violation (`phpcs`/`phpstan` previously didn't: the 3 PHPStan errors above went undetected by CI since this repo's first PHPStan adoption) - **Makefile:** `phpcs` target now calls `vendor/bin/phpcs --warning-severity=0` directly instead of `composer run phpcs`, matching exactly what CI's own PHPCS step runs — previously `make phpcs` treated pre-existing warnings (discouraged `json_encode()`/`file_get_contents()` calls, a missing nonce-verification annotation) as failures that CI itself was configured to ignore, so the two disagreed on what counted as passing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de6906a..6a2f35d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -158,12 +158,20 @@ final class YourComponent implements LoadableInterface { - **PHPCS**: Code standards compliance - **PHPStan**: Static analysis - **Composer validation**: Dependency management +- **PHPUnit**: Unit + WPGraphQL integration tests (per sub-plugin, `tests/Unit` and `tests/Integration`) -Run all tests: +Run linting/static analysis for all sub-plugins: ```bash make test ``` +Run a sub-plugin's PHPUnit suite (requires a WordPress test environment — set up once, shared by all three sub-plugins): +```bash +bash scripts/install-wp-tests.sh wordpress_test root '' localhost latest +bash scripts/install-wpgraphql-for-tests.sh +cd community-listings && WP_TESTS_DIR=/tmp/wordpress-tests-lib composer run phpunit +``` + ## 📤 Pull Request Process 1. **Update documentation** if needed diff --git a/README.md b/README.md index 28b0c63..5e92f73 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,18 @@ make phpstan # Static analysis (Level 8) make lint # Run PHPCS + PHPStan together ``` +### Automated Tests + +Each sub-plugin has its own PHPUnit suite (`tests/Unit`, `tests/Integration`) covering business logic and +WPGraphQL registration behavior. They share one WordPress test environment, set up once: + +```bash +bash scripts/install-wp-tests.sh wordpress_test root '' localhost latest +bash scripts/install-wpgraphql-for-tests.sh + +cd community-listings && WP_TESTS_DIR=/tmp/wordpress-tests-lib composer run phpunit +``` + ## 🤝 Contributing 1. Fork the repository diff --git a/community-listings/README.md b/community-listings/README.md index 07d46a1..501985f 100644 --- a/community-listings/README.md +++ b/community-listings/README.md @@ -21,7 +21,11 @@ community-listings/ ├── composer.json # Composer config with PSR-4 autoload ├── phpcs.xml # WPCS configuration ├── phpstan.neon # PHPStan Level 8 configuration +├── phpunit.xml # PHPUnit configuration ├── includes/ +│ ├── Admin/ +│ │ ├── ProviderListingsMetaBox.php # Provider Listings meta box (priority 25) +│ │ └── SettingsPage.php # Admin settings UI (priority 30) │ ├── Core/ │ │ ├── Activator.php # Activation/deactivation handlers │ │ └── Plugin.php # Singleton bootstrap (extends wp-plugin-kernel's AbstractPlugin) @@ -29,6 +33,10 @@ community-listings/ │ ├── CptRegistrar.php # CPT + meta field registration (priority 10) │ ├── GraphQLResolver.php # WPGraphQL do_shortcode() (priority 20) │ └── RestApiFilters.php # REST API query filters (priority 20) +└── tests/ + ├── bootstrap.php # PHPUnit bootstrap (WP test suite + WPGraphQL) + ├── Unit/ # Business-logic tests + └── Integration/ # WPGraphQL registration tests ``` ### Component Loading Order diff --git a/community-listings/composer.json b/community-listings/composer.json index f61486d..ec788e1 100644 --- a/community-listings/composer.json +++ b/community-listings/composer.json @@ -10,17 +10,25 @@ }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "php-stubs/wordpress-tests-stubs": "^6.6", "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6", "silverassist/coding-standards": "^1.0", "silverassist/wp-coding-standards": "^1.0", "szepeviktor/phpstan-wordpress": "^2.0", - "wp-coding-standards/wpcs": "^3.1" + "wp-coding-standards/wpcs": "^3.1", + "yoast/phpunit-polyfills": "^4.0" }, "autoload": { "psr-4": { "SilverAssist\\CommunityListings\\": "includes/" } }, + "autoload-dev": { + "psr-4": { + "SilverAssist\\CommunityListings\\Tests\\": "tests/" + } + }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true @@ -32,6 +40,13 @@ }, "scripts": { "phpcs": "phpcs", - "phpstan": "phpstan analyse --memory-limit=512M" + "phpcbf": "phpcbf", + "phpstan": "phpstan analyse --memory-limit=512M", + "phpunit": "phpunit", + "test": [ + "@phpcs", + "@phpstan", + "@phpunit" + ] } } diff --git a/community-listings/phpcs.xml b/community-listings/phpcs.xml index b7fc8d2..40d8515 100644 --- a/community-listings/phpcs.xml +++ b/community-listings/phpcs.xml @@ -5,6 +5,7 @@ . vendor/* node_modules/* + tests/* diff --git a/community-listings/phpunit.xml b/community-listings/phpunit.xml new file mode 100644 index 0000000..afe76fa --- /dev/null +++ b/community-listings/phpunit.xml @@ -0,0 +1,42 @@ + + + + + ./includes + + + ./vendor + ./tests + + + + + + + + + ./tests/Unit + + + ./tests/Integration + + + + + + + + + + diff --git a/community-listings/tests/Integration/CptRegistrarGraphQLTest.php b/community-listings/tests/Integration/CptRegistrarGraphQLTest.php new file mode 100644 index 0000000..546b0d3 --- /dev/null +++ b/community-listings/tests/Integration/CptRegistrarGraphQLTest.php @@ -0,0 +1,61 @@ +markTestSkipped( 'WPGraphQL plugin not available in the test environment.' ); + } + + $this->registrar = CptRegistrar::instance(); + CptRegistrar::register_post_type(); + } + + public function test_init_registers_graphql_register_types_hook(): void { + $this->registrar->init(); + + $this->assertNotFalse( \has_action( 'graphql_register_types', [ $this->registrar, 'register_graphql_meta_fields' ] ) ); + } + + /** + * The only test in this class that actually performs live WPGraphQL type + * registration — see class docblock for why it must stay singular. + */ + public function test_register_graphql_meta_fields_builds_queryable_schema(): void { + $this->registrar->register_graphql_meta_fields(); + + // A plain data query (not introspection, which WPGraphQL blocks for public + // requests by default) is enough to force a real schema build. + $result = \graphql( [ 'query' => '{ communities { nodes { id } } }' ] ); + + $this->assertArrayNotHasKey( 'errors', $result, 'Schema must build successfully after register_graphql_meta_fields() runs.' ); + + $type = \WPGraphQL::get_type_registry()->get_type( 'CommunityMeta' ); + $this->assertNotNull( $type, 'CommunityMeta GraphQL object type should be registered.' ); + } +} diff --git a/community-listings/tests/Integration/GraphQLResolverTest.php b/community-listings/tests/Integration/GraphQLResolverTest.php new file mode 100644 index 0000000..b205c07 --- /dev/null +++ b/community-listings/tests/Integration/GraphQLResolverTest.php @@ -0,0 +1,83 @@ +markTestSkipped( 'WPGraphQL plugin not available in the test environment.' ); + } + + $this->resolver = GraphQLResolver::instance(); + } + + public function test_init_registers_resolve_field_filter(): void { + $this->resolver->init(); + + $this->assertNotFalse( \has_filter( 'graphql_resolve_field', [ $this->resolver, 'resolve_shortcodes' ] ) ); + } + + public function test_init_registers_graphql_register_types_action(): void { + $this->resolver->init(); + + $this->assertNotFalse( \has_action( 'graphql_register_types', [ $this->resolver, 'register_rendered_content' ] ) ); + } + + public function test_resolve_shortcodes_only_applies_to_community_content_and_excerpt(): void { + \add_shortcode( 'cl_test_shortcode', fn() => 'RENDERED' ); + + $raw = 'Text [cl_test_shortcode] here'; + + $community_content = $this->resolver->resolve_shortcodes( $raw, null, [], null, null, 'Community', 'content', null, null ); + $this->assertSame( 'Text RENDERED here', $community_content ); + + $other_type = $this->resolver->resolve_shortcodes( $raw, null, [], null, null, 'Post', 'content', null, null ); + $this->assertSame( $raw, $other_type, 'Non-Community types must not have shortcodes processed.' ); + + $other_field = $this->resolver->resolve_shortcodes( $raw, null, [], null, null, 'Community', 'title', null, null ); + $this->assertSame( $raw, $other_field, 'Fields other than content/excerpt must not have shortcodes processed.' ); + + \remove_shortcode( 'cl_test_shortcode' ); + } + + public function test_resolve_shortcodes_skips_content_without_brackets(): void { + $plain = 'No shortcodes in this text.'; + + $result = $this->resolver->resolve_shortcodes( $plain, null, [], null, null, 'Community', 'content', null, null ); + + $this->assertSame( $plain, $result ); + } + + public function test_register_rendered_content_skips_when_graphql_shortcode_support_present(): void { + if ( ! \class_exists( \SilverAssist\GraphQLShortcodeSupport\Core\Plugin::class ) ) { + $this->markTestSkipped( 'graphql-shortcode-support is not loaded in this test run; duplicate-field guard is not exercised here.' ); + } + + // The method returns early via class_exists() before calling register_graphql_field() — + // this just documents/exercises that early-return path without a fatal. Calling + // register_graphql_field() unconditionally here (the "absent" branch) is deliberately + // NOT exercised as a second test: WPGraphQL's TypeRegistry treats a repeated direct + // call as a real duplicate field registration (DUPLICATE_FIELD), so it can only safely + // run once per process — the hook-registration assertion above already covers that + // the callback is wired correctly for the natural single schema-build call. + $this->resolver->register_rendered_content(); + + $this->assertTrue( true ); + } +} diff --git a/community-listings/tests/Unit/ActivatorTest.php b/community-listings/tests/Unit/ActivatorTest.php new file mode 100644 index 0000000..3235191 --- /dev/null +++ b/community-listings/tests/Unit/ActivatorTest.php @@ -0,0 +1,28 @@ +assertTrue( \post_type_exists( 'community' ) ); + + $post_type_object = \get_post_type_object( 'community' ); + $this->assertNotNull( $post_type_object ); + $this->assertTrue( $post_type_object->hierarchical ); + $this->assertSame( 'community', $post_type_object->graphql_single_name ); + } +} diff --git a/community-listings/tests/Unit/CptRegistrarTest.php b/community-listings/tests/Unit/CptRegistrarTest.php new file mode 100644 index 0000000..e7deaf3 --- /dev/null +++ b/community-listings/tests/Unit/CptRegistrarTest.php @@ -0,0 +1,86 @@ +assertSame( 10, CptRegistrar::instance()->get_priority() ); + } + + public function test_should_load_is_true(): void { + $this->assertTrue( CptRegistrar::instance()->should_load() ); + } + + public function test_register_post_type_sets_expected_args(): void { + CptRegistrar::register_post_type(); + + $this->assertTrue( \post_type_exists( 'community' ) ); + + $object = \get_post_type_object( 'community' ); + $this->assertTrue( $object->hierarchical ); + $this->assertTrue( $object->show_in_graphql ); + $this->assertSame( 'community', $object->graphql_single_name ); + $this->assertSame( 'communities', $object->graphql_plural_name ); + $this->assertSame( 'community', $object->rest_base ); + } + + public function test_register_meta_registers_all_thirteen_fields(): void { + CptRegistrar::register_post_type(); + CptRegistrar::instance()->register_meta(); + + $registered = \get_registered_meta_keys( 'post', 'community' ); + + foreach ( self::EXPECTED_META_KEYS as $key ) { + $this->assertArrayHasKey( $key, $registered, "Meta key '{$key}' should be registered for the community post type." ); + } + } + + public function test_register_meta_boolean_fields_have_boolean_type(): void { + CptRegistrar::register_post_type(); + CptRegistrar::instance()->register_meta(); + + $registered = \get_registered_meta_keys( 'post', 'community' ); + + foreach ( [ 'hero_text_contrast', 'noindex', 'nofollow' ] as $key ) { + $this->assertSame( 'boolean', $registered[ $key ]['type'] ); + } + } + + public function test_register_meta_string_fields_have_string_type(): void { + CptRegistrar::register_post_type(); + CptRegistrar::instance()->register_meta(); + + $registered = \get_registered_meta_keys( 'post', 'community' ); + + $this->assertSame( 'string', $registered['state_short']['type'] ); + $this->assertSame( 'string', $registered['contentful_id']['type'] ); + } +} diff --git a/community-listings/tests/Unit/PluginTest.php b/community-listings/tests/Unit/PluginTest.php new file mode 100644 index 0000000..581d477 --- /dev/null +++ b/community-listings/tests/Unit/PluginTest.php @@ -0,0 +1,48 @@ +setAccessible( true ); + + $components = $reflection->invoke( Plugin::instance() ); + + $this->assertSame( + [ + CptRegistrar::class, + RestApiFilters::class, + GraphQLResolver::class, + ProviderListingsMetaBox::class, + SettingsPage::class, + ], + $components + ); + } + + public function test_plugin_bootstraps_and_registers_cpt(): void { + Plugin::instance()->init(); + + \do_action( 'init' ); + + $this->assertTrue( \post_type_exists( 'community' ) ); + } +} diff --git a/community-listings/tests/Unit/ProviderListingsMetaBoxTest.php b/community-listings/tests/Unit/ProviderListingsMetaBoxTest.php new file mode 100644 index 0000000..bd88ee7 --- /dev/null +++ b/community-listings/tests/Unit/ProviderListingsMetaBoxTest.php @@ -0,0 +1,156 @@ +meta_box = ProviderListingsMetaBox::instance(); + + $admin_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin_id ); + } + + protected function tearDown(): void { + $_POST = []; + $_GET = []; + global $wp_meta_boxes; + $wp_meta_boxes = []; + parent::tearDown(); + } + + private function create_community_post( string $listing_type ): int { + $post_id = static::factory()->post->create( [ 'post_type' => 'community' ] ); + \update_post_meta( $post_id, 'listing_type', $listing_type ); + return $post_id; + } + + public function test_get_priority_is_twenty_five(): void { + $this->assertSame( 25, $this->meta_box->get_priority() ); + } + + public function test_should_load_is_true(): void { + $this->assertTrue( $this->meta_box->should_load() ); + } + + public function test_register_meta_box_registers_for_city_listing(): void { + $post_id = $this->create_community_post( 'city' ); + + $this->meta_box->register_meta_box( \get_post( $post_id ) ); + + global $wp_meta_boxes; + $this->assertArrayHasKey( 'provider-listings-meta-box', $wp_meta_boxes['community']['normal']['high'] ?? [] ); + } + + public function test_register_meta_box_skips_non_city_listing(): void { + $post_id = $this->create_community_post( 'state' ); + + $this->meta_box->register_meta_box( \get_post( $post_id ) ); + + global $wp_meta_boxes; + $this->assertArrayNotHasKey( 'provider-listings-meta-box', $wp_meta_boxes['community']['normal']['high'] ?? [] ); + } + + public function test_save_provider_listings_rejects_invalid_json(): void { + $post_id = $this->create_community_post( 'city' ); + + $_POST[ self::NONCE_FIELD ] = \wp_create_nonce( self::NONCE_ACTION ); + $_POST[ self::FIELD_NAME ] = '{not valid json'; + + $this->meta_box->save_provider_listings( $post_id, \get_post( $post_id ), true ); + + $this->assertSame( '', \get_post_meta( $post_id, 'provider_listings', true ) ); + $this->assertNotFalse( \has_filter( 'redirect_post_location', [ $this->meta_box, 'append_invalid_json_query_arg' ] ) ); + } + + public function test_save_provider_listings_rejects_invalid_nonce(): void { + $post_id = $this->create_community_post( 'city' ); + + $_POST[ self::NONCE_FIELD ] = 'not-a-real-nonce'; + $_POST[ self::FIELD_NAME ] = '[{"name":"Acme Home Care"}]'; + + $this->meta_box->save_provider_listings( $post_id, \get_post( $post_id ), true ); + + $this->assertSame( '', \get_post_meta( $post_id, 'provider_listings', true ) ); + } + + public function test_save_provider_listings_skips_non_city_posts(): void { + $post_id = $this->create_community_post( 'state' ); + + $_POST[ self::NONCE_FIELD ] = \wp_create_nonce( self::NONCE_ACTION ); + $_POST[ self::FIELD_NAME ] = '[{"name":"Acme Home Care"}]'; + + $this->meta_box->save_provider_listings( $post_id, \get_post( $post_id ), true ); + + $this->assertSame( '', \get_post_meta( $post_id, 'provider_listings', true ) ); + } + + /** + * Regression test for the PR #2 fix: the direct $wpdb->update() write bypasses + * update_post_meta()'s own cache invalidation, so save_provider_listings() must + * call wp_cache_delete() itself — otherwise a subsequent get_post_meta() call + * within the same request keeps serving the pre-save cached value. + */ + public function test_save_provider_listings_invalidates_meta_cache(): void { + $post_id = $this->create_community_post( 'city' ); + + \update_post_meta( $post_id, 'provider_listings', '[{"name":"Old Provider"}]' ); + + // Prime the object cache for this post's meta. + $primed = \get_post_meta( $post_id, 'provider_listings', true ); + $this->assertSame( '[{"name":"Old Provider"}]', $primed ); + + $new_json = '[{"name":"New Provider"}]'; + $_POST[ self::NONCE_FIELD ] = \wp_create_nonce( self::NONCE_ACTION ); + $_POST[ self::FIELD_NAME ] = $new_json; + + $this->meta_box->save_provider_listings( $post_id, \get_post( $post_id ), true ); + + $this->assertSame( + $new_json, + \get_post_meta( $post_id, 'provider_listings', true ), + 'get_post_meta() must reflect the direct DB write immediately — a stale cache read here means wp_cache_delete() regressed.' + ); + } + + public function test_save_provider_listings_inserts_when_meta_row_missing(): void { + $post_id = $this->create_community_post( 'city' ); + // No prior provider_listings meta row exists for this post. + + $new_json = '[{"name":"Brand New Provider"}]'; + $_POST[ self::NONCE_FIELD ] = \wp_create_nonce( self::NONCE_ACTION ); + $_POST[ self::FIELD_NAME ] = $new_json; + + $this->meta_box->save_provider_listings( $post_id, \get_post( $post_id ), true ); + + $this->assertSame( $new_json, \get_post_meta( $post_id, 'provider_listings', true ) ); + } + + public function test_append_invalid_json_query_arg_adds_flag_and_removes_itself(): void { + \add_filter( 'redirect_post_location', [ $this->meta_box, 'append_invalid_json_query_arg' ] ); + + $result = $this->meta_box->append_invalid_json_query_arg( 'https://example.test/wp-admin/post.php' ); + + $this->assertStringContainsString( 'provider_listings_json_invalid=1', $result ); + $this->assertFalse( \has_filter( 'redirect_post_location', [ $this->meta_box, 'append_invalid_json_query_arg' ] ) ); + } +} diff --git a/community-listings/tests/Unit/RestApiFiltersTest.php b/community-listings/tests/Unit/RestApiFiltersTest.php new file mode 100644 index 0000000..f12f60c --- /dev/null +++ b/community-listings/tests/Unit/RestApiFiltersTest.php @@ -0,0 +1,93 @@ +filters = RestApiFilters::instance(); + } + + public function test_filter_query_adds_listing_type_meta_query(): void { + $request = new WP_REST_Request(); + $request->set_param( 'listing_type', 'city' ); + + $args = $this->filters->filter_query( [], $request ); + + $this->assertSame( + [ 'key' => 'listing_type', 'value' => 'city' ], + $args['meta_query'][0] + ); + } + + public function test_filter_query_uppercases_state_short(): void { + $request = new WP_REST_Request(); + $request->set_param( 'state_short', 'tx' ); + + $args = $this->filters->filter_query( [], $request ); + + $this->assertSame( + [ 'key' => 'state_short', 'value' => 'TX' ], + $args['meta_query'][0] + ); + } + + public function test_filter_query_keeps_state_long_as_is(): void { + $request = new WP_REST_Request(); + $request->set_param( 'state_long', 'texas' ); + + $args = $this->filters->filter_query( [], $request ); + + $this->assertSame( + [ 'key' => 'state_long', 'value' => 'texas' ], + $args['meta_query'][0] + ); + } + + public function test_filter_query_combines_multiple_params(): void { + $request = new WP_REST_Request(); + $request->set_param( 'listing_type', 'city' ); + $request->set_param( 'state_short', 'tx' ); + + $args = $this->filters->filter_query( [], $request ); + + $this->assertCount( 2, $args['meta_query'] ); + } + + public function test_filter_query_no_params_leaves_args_unchanged(): void { + $request = new WP_REST_Request(); + + $args = $this->filters->filter_query( [ 'existing' => 'value' ], $request ); + + $this->assertArrayNotHasKey( 'meta_query', $args ); + $this->assertSame( 'value', $args['existing'] ); + } + + public function test_register_params_adds_all_three_params(): void { + $params = $this->filters->register_params( [] ); + + $this->assertArrayHasKey( 'listing_type', $params ); + $this->assertArrayHasKey( 'state_short', $params ); + $this->assertArrayHasKey( 'state_long', $params ); + $this->assertSame( [ 'state', 'city' ], $params['listing_type']['enum'] ); + } + + public function test_get_priority_is_twenty(): void { + $this->assertSame( 20, $this->filters->get_priority() ); + } +} diff --git a/community-listings/tests/Unit/SettingsPageTest.php b/community-listings/tests/Unit/SettingsPageTest.php new file mode 100644 index 0000000..c421f90 --- /dev/null +++ b/community-listings/tests/Unit/SettingsPageTest.php @@ -0,0 +1,64 @@ +settings_page = SettingsPage::instance(); + } + + public function test_get_priority_is_thirty(): void { + $this->assertSame( 30, $this->settings_page->get_priority() ); + } + + public function test_should_load_is_true(): void { + $this->assertTrue( $this->settings_page->should_load() ); + } + + public function test_register_with_settings_hub_falls_back_to_standalone_when_hub_absent(): void { + if ( class_exists( \SilverAssist\SettingsHub\SettingsHub::class ) ) { + $this->markTestSkipped( 'Settings Hub package is present in this test run; standalone fallback path is not reachable.' ); + } + + $this->settings_page->register_with_settings_hub(); + + $this->assertNotFalse( \has_action( 'admin_menu', [ $this->settings_page, 'register_standalone_settings' ] ) ); + } + + public function test_render_settings_page_outputs_nothing_without_capability(): void { + \wp_set_current_user( 0 ); + + ob_start(); + $this->settings_page->render_settings_page(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + public function test_render_settings_page_outputs_html_for_admin(): void { + $admin_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin_id ); + + ob_start(); + $this->settings_page->render_settings_page(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Plugin Status', $output ); + $this->assertStringContainsString( 'community', $output ); + } +} diff --git a/community-listings/tests/bootstrap.php b/community-listings/tests/bootstrap.php new file mode 100644 index 0000000..c8e4dbe --- /dev/null +++ b/community-listings/tests/bootstrap.php @@ -0,0 +1,47 @@ + [db-host] [wp-version]\n\n"; + echo "ℹ️ Or set WP_TESTS_DIR to an existing WordPress test installation.\n\n"; + exit( 1 ); +} + +require_once "{$_tests_dir}/includes/functions.php"; + +/** + * Manually load the plugin being tested and its dependencies. + * + * @return void + */ +function _manually_load_plugin() { + // Load WPGraphQL if the shared test environment has it installed. + if ( defined( 'WP_PLUGIN_DIR' ) && file_exists( WP_PLUGIN_DIR . '/wp-graphql/wp-graphql.php' ) ) { + require_once WP_PLUGIN_DIR . '/wp-graphql/wp-graphql.php'; + } + + require dirname( __DIR__ ) . '/community-listings.php'; +} + +tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); + +require "{$_tests_dir}/includes/bootstrap.php"; diff --git a/contentful-tables/README.md b/contentful-tables/README.md index f2253f9..4657281 100644 --- a/contentful-tables/README.md +++ b/contentful-tables/README.md @@ -21,6 +21,7 @@ contentful-tables/ ├── composer.json # Composer config with PSR-4 autoload ├── phpcs.xml # WPCS configuration ├── phpstan.neon # PHPStan Level 8 configuration +├── phpunit.xml # PHPUnit configuration ├── includes/ │ ├── Admin/ │ │ └── SettingsPage.php # Admin settings page (priority 30) @@ -40,8 +41,12 @@ contentful-tables/ │ ├── FormRenderer.php # Form HTML renderer │ ├── TableRenderer.php # Data table HTML renderer │ └── TocRenderer.php # Table of contents HTML renderer -└── assets/ - └── contentful-tables.css # Optional external stylesheet +├── assets/ +│ └── contentful-tables.css # Optional external stylesheet +└── tests/ + ├── bootstrap.php # PHPUnit bootstrap (WP test suite + WPGraphQL) + ├── Unit/ # Business-logic tests + └── Integration/ # WPGraphQL registration tests ``` ### Component Loading Order diff --git a/contentful-tables/composer.json b/contentful-tables/composer.json index effdeed..5e71239 100644 --- a/contentful-tables/composer.json +++ b/contentful-tables/composer.json @@ -10,17 +10,25 @@ }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "php-stubs/wordpress-tests-stubs": "^6.6", "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6", "silverassist/coding-standards": "^1.0", "silverassist/wp-coding-standards": "^1.0", "szepeviktor/phpstan-wordpress": "^2.0", - "wp-coding-standards/wpcs": "^3.1" + "wp-coding-standards/wpcs": "^3.1", + "yoast/phpunit-polyfills": "^4.0" }, "autoload": { "psr-4": { "SilverAssist\\ContentfulTables\\": "includes/" } }, + "autoload-dev": { + "psr-4": { + "SilverAssist\\ContentfulTables\\Tests\\": "tests/" + } + }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true @@ -32,6 +40,13 @@ }, "scripts": { "phpcs": "phpcs", - "phpstan": "phpstan analyse --memory-limit=512M" + "phpcbf": "phpcbf", + "phpstan": "phpstan analyse --memory-limit=512M", + "phpunit": "phpunit", + "test": [ + "@phpcs", + "@phpstan", + "@phpunit" + ] } } diff --git a/contentful-tables/includes/Service/TableDataLoader.php b/contentful-tables/includes/Service/TableDataLoader.php index 27e0de8..e8268b4 100644 --- a/contentful-tables/includes/Service/TableDataLoader.php +++ b/contentful-tables/includes/Service/TableDataLoader.php @@ -433,7 +433,7 @@ private function load_tables_from_database(): void { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is prefixed, not user input. - "SELECT table_id, table_data FROM {$table_name}", + "SELECT entry_id, table_data FROM {$table_name}", ARRAY_A ); @@ -444,7 +444,7 @@ private function load_tables_from_database(): void { foreach ( $results as $row ) { $table_data = \json_decode( $row['table_data'], true ); if ( $table_data ) { - $this->tables_data[ $row['table_id'] ] = $table_data; + $this->tables_data[ $row['entry_id'] ] = $table_data; } } } diff --git a/contentful-tables/phpcs.xml b/contentful-tables/phpcs.xml index 928ed53..85cd921 100644 --- a/contentful-tables/phpcs.xml +++ b/contentful-tables/phpcs.xml @@ -5,6 +5,7 @@ . vendor/* node_modules/* + tests/* diff --git a/contentful-tables/phpunit.xml b/contentful-tables/phpunit.xml new file mode 100644 index 0000000..b26f20e --- /dev/null +++ b/contentful-tables/phpunit.xml @@ -0,0 +1,42 @@ + + + + + ./includes + + + ./vendor + ./tests + + + + + + + + + ./tests/Unit + + + ./tests/Integration + + + + + + + + + + diff --git a/contentful-tables/tests/Integration/GraphQLResolverTest.php b/contentful-tables/tests/Integration/GraphQLResolverTest.php new file mode 100644 index 0000000..85cf1b7 --- /dev/null +++ b/contentful-tables/tests/Integration/GraphQLResolverTest.php @@ -0,0 +1,83 @@ +markTestSkipped( 'WPGraphQL plugin not available in the test environment.' ); + } + + $this->resolver = GraphQLResolver::instance(); + } + + public function test_init_registers_resolve_field_filter(): void { + $this->resolver->init(); + + $this->assertNotFalse( \has_filter( 'graphql_resolve_field', [ $this->resolver, 'resolve_shortcodes' ] ) ); + } + + public function test_init_registers_graphql_register_types_action(): void { + $this->resolver->init(); + + $this->assertNotFalse( \has_action( 'graphql_register_types', [ $this->resolver, 'register_rendered_content' ] ) ); + } + + public function test_resolve_shortcodes_applies_to_post_page_and_community(): void { + \add_shortcode( 'ct_test_shortcode', fn() => 'RENDERED' ); + $raw = 'Text [ct_test_shortcode] here'; + + foreach ( [ 'Post', 'Page', 'Community' ] as $type ) { + $result = $this->resolver->resolve_shortcodes( $raw, null, [], null, null, $type, 'content', null, null ); + $this->assertSame( 'Text RENDERED here', $result, "Type {$type} should have shortcodes processed." ); + } + + \remove_shortcode( 'ct_test_shortcode' ); + } + + public function test_resolve_shortcodes_ignores_other_types(): void { + $raw = 'Text [ct_test_shortcode] here'; + + $result = $this->resolver->resolve_shortcodes( $raw, null, [], null, null, 'CustomType', 'content', null, null ); + + $this->assertSame( $raw, $result ); + } + + public function test_resolve_shortcodes_ignores_fields_other_than_content_and_excerpt(): void { + $raw = 'Text [ct_test_shortcode] here'; + + $result = $this->resolver->resolve_shortcodes( $raw, null, [], null, null, 'Post', 'title', null, null ); + + $this->assertSame( $raw, $result ); + } + + public function test_resolve_shortcodes_skips_content_without_brackets(): void { + $plain = 'No shortcodes here.'; + + $result = $this->resolver->resolve_shortcodes( $plain, null, [], null, null, 'Post', 'content', null, null ); + + $this->assertSame( $plain, $result ); + } + + public function test_resolve_shortcodes_ignores_non_string_result(): void { + $result = $this->resolver->resolve_shortcodes( 42, null, [], null, null, 'Post', 'content', null, null ); + + $this->assertSame( 42, $result ); + } +} diff --git a/contentful-tables/tests/Integration/TableDataLoaderTest.php b/contentful-tables/tests/Integration/TableDataLoaderTest.php new file mode 100644 index 0000000..70a7ac0 --- /dev/null +++ b/contentful-tables/tests/Integration/TableDataLoaderTest.php @@ -0,0 +1,231 @@ + post meta -> database) for tables, plus a + * separate 4-source resolution (inline / rawData / cached CSV / remote + * spreadsheet) for get_card_rows(). + * + * @package SilverAssist\ContentfulTables\Tests\Integration + */ + +namespace SilverAssist\ContentfulTables\Tests\Integration; + +use SilverAssist\ContentfulTables\Core\Activator; +use SilverAssist\ContentfulTables\Service\TableDataLoader; +use SilverAssist\PluginKernel\Testing\TestCase; + +/** + * @covers \SilverAssist\ContentfulTables\Service\TableDataLoader + */ +class TableDataLoaderTest extends TestCase { + + private TableDataLoader $loader; + + /** @var string[] Files created during a test, cleaned up in tearDown(). */ + private array $created_files = []; + + protected function setUp(): void { + parent::setUp(); + + $this->loader = TableDataLoader::instance(); + $this->reset_loader_state(); + + \delete_option( 'contentful_tables_source' ); + \delete_option( 'contentful_tables_post_id' ); + } + + protected function tearDown(): void { + foreach ( $this->created_files as $file ) { + if ( \file_exists( $file ) ) { + \unlink( $file ); + } + } + $this->created_files = []; + + foreach ( [ WP_CONTENT_DIR . '/contentful-tables', WP_CONTENT_DIR . '/contentful-charts', WP_CONTENT_DIR . '/contentful-cards' ] as $dir ) { + if ( \is_dir( $dir ) && [] === \glob( $dir . '/*' ) ) { + \rmdir( $dir ); + } + } + + \remove_all_filters( 'pre_http_request' ); + + parent::tearDown(); + } + + /** + * TableDataLoader is a kernel singleton whose loaded-data arrays are never + * cleared between calls (by design — load_tables_data()'s fallback chain + * relies on "still empty" to decide whether to try the next source). Tests + * must reset that private state directly to get a clean slate each time. + */ + private function reset_loader_state(): void { + $reflection = new \ReflectionClass( TableDataLoader::class ); + foreach ( [ 'tables_data', 'charts_data', 'cards_data' ] as $prop ) { + $property = $reflection->getProperty( $prop ); + $property->setAccessible( true ); + $property->setValue( $this->loader, [] ); + } + } + + private function write_file( string $path, string $contents ): void { + \wp_mkdir_p( \dirname( $path ) ); + \file_put_contents( $path, $contents ); + $this->created_files[] = $path; + } + + public function test_get_priority_is_ten(): void { + $this->assertSame( 10, $this->loader->get_priority() ); + } + + public function test_load_all_data_loads_json_table_from_files(): void { + $this->write_file( + WP_CONTENT_DIR . '/contentful-tables/my-table.json', + \wp_json_encode( [ 'type' => 'Plain', 'title' => 'My Table' ] ) + ); + + $this->loader->load_all_data(); + + $this->assertSame( [ 'type' => 'Plain', 'title' => 'My Table' ], $this->loader->get_table( 'my-table' ) ); + $this->assertSame( 'files', \get_option( 'contentful_tables_source' ) ); + } + + public function test_load_all_data_loads_csv_table_with_key_column(): void { + $this->write_file( + WP_CONTENT_DIR . '/contentful-tables/csv-table.csv', + "key,name\nfood,Acme Food\nagency,Acme Agency\n" + ); + + $this->loader->load_all_data(); + + $table = $this->loader->get_table( 'csv-table' ); + + $this->assertNotNull( $table ); + $this->assertSame( 'key', $table['keyColumn'] ); + $this->assertSame( 0, $table['keyColumnIndex'] ); + $this->assertSame( [ 'food', 'agency' ], $table['keyValues'] ); + } + + public function test_load_all_data_falls_back_to_post_meta_when_no_files(): void { + \wp_insert_post( + [ + 'import_id' => 241, + 'post_title' => 'Legacy table source post', + 'post_status' => 'publish', + 'post_type' => 'post', + ] + ); + \update_post_meta( 241, 'contentful_table_from_meta', \wp_json_encode( [ 'type' => 'Plain', 'title' => 'From Meta' ] ) ); + + $this->loader->load_all_data(); + + $this->assertSame( [ 'type' => 'Plain', 'title' => 'From Meta' ], $this->loader->get_table( 'from_meta' ) ); + $this->assertSame( '241', (string) \get_option( 'contentful_tables_post_id' ) ); + } + + /** + * Regression test: load_tables_from_database() previously selected a + * non-existent `table_id` column (schema only has `entry_id`), which + * would have thrown a real SQL error the first time this fallback path + * was ever reached in production. + */ + public function test_load_all_data_falls_back_to_database_when_no_files_or_post_meta(): void { + Activator::activate(); + + global $wpdb; + $table_name = $wpdb->prefix . 'contentful_tables'; + $wpdb->insert( + $table_name, + [ + 'entry_id' => 'db-table', + 'table_name' => 'DB Table', + 'table_data' => \wp_json_encode( [ 'type' => 'Plain', 'title' => 'From Database' ] ), + ] + ); + + $this->loader->load_all_data(); + + $this->assertSame( [ 'type' => 'Plain', 'title' => 'From Database' ], $this->loader->get_table( 'db-table' ) ); + + $wpdb->query( "TRUNCATE TABLE {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + public function test_load_all_data_loads_charts_from_files(): void { + $this->write_file( + WP_CONTENT_DIR . '/contentful-charts/my-chart.json', + \wp_json_encode( [ 'title' => 'My Chart' ] ) + ); + + $this->loader->load_all_data(); + + $this->assertSame( [ 'title' => 'My Chart' ], $this->loader->get_chart( 'my-chart' ) ); + } + + public function test_load_all_data_loads_cards_from_files(): void { + $this->write_file( + WP_CONTENT_DIR . '/contentful-cards/my-card.json', + \wp_json_encode( [ 'title' => 'My Card' ] ) + ); + + $this->loader->load_all_data(); + + $this->assertSame( [ 'title' => 'My Card' ], $this->loader->get_card( 'my-card' ) ); + } + + public function test_get_card_rows_source_inline_table_data(): void { + $rows = $this->loader->get_card_rows( [ 'source' => [ 'dataTable' => [ 'tableData' => [ [ 'a' ], [ '1' ] ] ] ] ] ); + + $this->assertSame( [ [ 'a' ], [ '1' ] ], $rows ); + } + + public function test_get_card_rows_source_raw_data(): void { + $rows = $this->loader->get_card_rows( [ 'rawData' => [ [ 'a' ], [ '1' ] ] ] ); + + $this->assertSame( [ [ 'a' ], [ '1' ] ], $rows ); + } + + public function test_get_card_rows_source_cached_csv_file(): void { + $card_id = 'csv-card-' . \uniqid(); + $this->write_file( WP_CONTENT_DIR . "/contentful-cards/{$card_id}.csv", "a,b\n1,2\n" ); + + $rows = $this->loader->get_card_rows( [ 'id' => $card_id ] ); + + $this->assertSame( [ [ 'a', 'b' ], [ '1', '2' ] ], $rows ); + + \delete_transient( 'ctfl_card_csv_' . \substr( \md5( $card_id ), 0, 16 ) ); + } + + public function test_get_card_rows_source_remote_spreadsheet_downloads_and_caches(): void { + \add_filter( + 'pre_http_request', + static function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => "a,b\nx,y\n", + ]; + } + ); + + $card_id = 'remote-card-' . \uniqid(); + $csv_path = WP_CONTENT_DIR . "/contentful-cards/{$card_id}.csv"; + \wp_mkdir_p( \dirname( $csv_path ) ); + + $rows = $this->loader->get_card_rows( + [ + 'id' => $card_id, + 'source' => [ 'type' => 'spreadsheet', 'url' => 'https://example.test/data.csv' ], + ] + ); + + $this->assertSame( [ [ 'a', 'b' ], [ 'x', 'y' ] ], $rows ); + $this->assertFileExists( $csv_path ); + + $this->created_files[] = $csv_path; + } + + public function test_get_card_rows_returns_empty_array_when_no_source_matches(): void { + $rows = $this->loader->get_card_rows( [] ); + + $this->assertSame( [], $rows ); + } +} diff --git a/contentful-tables/tests/Unit/ActivatorTest.php b/contentful-tables/tests/Unit/ActivatorTest.php new file mode 100644 index 0000000..cfa7ba4 --- /dev/null +++ b/contentful-tables/tests/Unit/ActivatorTest.php @@ -0,0 +1,59 @@ +prefix . 'contentful_tables'; + $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); + + $this->assertSame( $table_name, $exists ); + } + + public function test_activate_creates_table_with_expected_columns(): void { + global $wpdb; + + $table_name = $wpdb->prefix . 'contentful_tables'; + $columns = $wpdb->get_col( "DESCRIBE {$table_name}" ); + + foreach ( [ 'id', 'entry_id', 'table_name', 'table_data', 'created_at', 'updated_at' ] as $expected ) { + $this->assertContains( $expected, $columns ); + } + } + + public function test_activate_sets_version_option(): void { + $this->assertSame( CTFL_TABLES_VERSION, \get_option( 'contentful_tables_version' ) ); + } + + public function test_deactivate_removes_flush_needed_option(): void { + \update_option( 'contentful_tables_flush_needed', 'yes' ); + + Activator::deactivate(); + + $this->assertFalse( \get_option( 'contentful_tables_flush_needed' ) ); + } +} diff --git a/contentful-tables/tests/Unit/CardsRendererTest.php b/contentful-tables/tests/Unit/CardsRendererTest.php new file mode 100644 index 0000000..d8cfd21 --- /dev/null +++ b/contentful-tables/tests/Unit/CardsRendererTest.php @@ -0,0 +1,107 @@ +loader = TableDataLoader::instance(); + } + + public function test_render_card_grid_from_raw_data(): void { + $html = CardsRenderer::render( + [ 'rawData' => [ [ 'name', 'phone' ], [ 'Acme Home Care', '555-1234' ] ] ], + 'card-1', + [], + $this->loader + ); + + $this->assertStringContainsString( 'contentful-card', $html ); + $this->assertStringContainsString( 'Acme Home Care', $html ); + $this->assertStringContainsString( '555-1234', $html ); + } + + public function test_render_hides_key_column_by_default(): void { + $html = CardsRenderer::render( + [ 'rawData' => [ [ 'key', 'name' ], [ 'food', 'Acme Food' ] ] ], + 'card-2', + [], + $this->loader + ); + + $this->assertStringNotContainsString( 'card-label">Key', $html ); + } + + public function test_render_filters_rows_by_explicit_key(): void { + $html = CardsRenderer::render( + [ 'rawData' => [ [ 'key', 'name' ], [ 'food', 'Acme Food' ], [ 'agency', 'Acme Agency' ] ] ], + 'card-3', + [ 'filters' => 'food' ], + $this->loader + ); + + $this->assertStringContainsString( 'Acme Food', $html ); + $this->assertStringNotContainsString( 'Acme Agency', $html ); + } + + public function test_render_shows_placeholder_when_no_card_data(): void { + $html = CardsRenderer::render( null, 'card-4', [], $this->loader ); + + $this->assertStringContainsString( 'cards-placeholder', $html ); + } + + public function test_render_shows_no_listings_message_when_filter_matches_nothing(): void { + $html = CardsRenderer::render( + [ 'rawData' => [ [ 'key', 'name' ], [ 'food', 'Acme Food' ] ] ], + 'card-5', + [ 'filters' => 'nonexistent' ], + $this->loader + ); + + $this->assertStringContainsString( 'No listings found.', $html ); + } + + public function test_render_uses_selected_columns_when_configured(): void { + $html = CardsRenderer::render( + [ + 'rawData' => [ [ 'name', 'phone', 'address' ], [ 'Acme', '555-1234', '123 Main St' ] ], + 'filters' => [ 'selectedColumns' => [ [ 'name' => 'name' ] ] ], + ], + 'card-6', + [], + $this->loader + ); + + $this->assertStringContainsString( 'Acme', $html ); + $this->assertStringNotContainsString( '555-1234', $html ); + } + + public function test_render_resolves_title_placeholder(): void { + $post_id = static::factory()->post->create( [ 'post_name' => 'birmingham-al' ] ); + $GLOBALS['post'] = \get_post( $post_id ); + + $html = CardsRenderer::render( + [ 'title' => 'Listings in [city-state]', 'rawData' => [ [ 'a' ], [ '1' ] ] ], + 'card-7', + [], + $this->loader + ); + + $this->assertStringContainsString( 'Listings in Birmingham, AL', $html ); + } +} diff --git a/contentful-tables/tests/Unit/ChartRendererTest.php b/contentful-tables/tests/Unit/ChartRendererTest.php new file mode 100644 index 0000000..aa9b48c --- /dev/null +++ b/contentful-tables/tests/Unit/ChartRendererTest.php @@ -0,0 +1,70 @@ + 'My Chart', + 'source' => [ 'type' => 'table', 'dataTable' => [ 'tableData' => [ [ 'Month', 'Sales' ], [ 'Jan', '100' ] ] ] ], + ], + 'chart-1', + [] + ); + + $this->assertStringContainsString( 'chart-title', $html ); + $this->assertStringContainsString( 'My Chart', $html ); + $this->assertStringContainsString( 'Month', $html ); + $this->assertStringContainsString( 'Jan', $html ); + } + + public function test_render_applies_numeric_label_prefix(): void { + $html = ChartRenderer::render( + [ + 'source' => [ 'type' => 'table', 'dataTable' => [ 'tableData' => [ [ 'Month', 'Sales' ], [ 'Jan', '1000' ] ] ] ], + 'labelPrefix' => '$', + ], + 'chart-2', + [] + ); + + $this->assertStringContainsString( '$1,000', $html ); + } + + public function test_render_spreadsheet_source_shows_download_link(): void { + $html = ChartRenderer::render( + [ 'source' => [ 'type' => 'spreadsheet', 'url' => 'https://example.test/data.csv' ] ], + 'chart-3', + [] + ); + + $this->assertStringContainsString( 'https://example.test/data.csv', $html ); + } + + public function test_render_shows_placeholder_when_no_chart_data(): void { + $html = ChartRenderer::render( null, 'chart-4', [ 'type' => 'bar' ] ); + + $this->assertStringContainsString( 'chart-placeholder', $html ); + $this->assertStringContainsString( 'bar', $html ); + } + + public function test_render_custom_title_overrides_data_title(): void { + $html = ChartRenderer::render( [ 'title' => 'Data Title' ], 'chart-5', [ 'title' => 'Custom Title' ] ); + + $this->assertStringContainsString( 'Custom Title', $html ); + $this->assertStringNotContainsString( 'Data Title', $html ); + } +} diff --git a/contentful-tables/tests/Unit/CsvParserTest.php b/contentful-tables/tests/Unit/CsvParserTest.php new file mode 100644 index 0000000..c52d954 --- /dev/null +++ b/contentful-tables/tests/Unit/CsvParserTest.php @@ -0,0 +1,74 @@ +assertSame( [ [ 'a', 'b', 'c' ], [ '1', '2', '3' ] ], $rows ); + } + + public function test_parse_quoted_fields_with_commas(): void { + $rows = CsvParser::parse( 'name,note' . "\n" . '"Acme, Inc.",hello' ); + + $this->assertSame( [ [ 'name', 'note' ], [ 'Acme, Inc.', 'hello' ] ], $rows ); + } + + public function test_parse_escaped_quotes_inside_quoted_field(): void { + $rows = CsvParser::parse( 'quote' . "\n" . '"She said ""hi""."' ); + + $this->assertSame( [ [ 'quote' ], [ 'She said "hi".' ] ], $rows ); + } + + public function test_parse_multiline_value_inside_quotes(): void { + $csv = "note\n\"Line one\nLine two\""; + + $rows = CsvParser::parse( $csv ); + + $this->assertSame( [ [ 'note' ], [ "Line one\nLine two" ] ], $rows ); + } + + public function test_parse_crlf_line_endings(): void { + $rows = CsvParser::parse( "a,b\r\n1,2\r\n" ); + + $this->assertSame( [ [ 'a', 'b' ], [ '1', '2' ] ], $rows ); + } + + public function test_parse_strips_utf8_bom(): void { + $rows = CsvParser::parse( "\xEF\xBB\xBFa,b\n1,2" ); + + $this->assertSame( 'a', $rows[0][0] ); + } + + public function test_parse_filters_fully_empty_rows(): void { + $rows = CsvParser::parse( "a,b\n,\n1,2" ); + + $this->assertSame( [ [ 'a', 'b' ], [ '1', '2' ] ], $rows ); + } + + public function test_parse_captures_last_row_without_trailing_newline(): void { + $rows = CsvParser::parse( "a,b\n1,2" ); + + $this->assertCount( 2, $rows ); + $this->assertSame( [ '1', '2' ], $rows[1] ); + } + + public function test_parse_trims_cell_whitespace(): void { + $rows = CsvParser::parse( "a, b ,c\n" ); + + $this->assertSame( [ 'a', 'b', 'c' ], $rows[0] ); + } +} diff --git a/contentful-tables/tests/Unit/FormRendererTest.php b/contentful-tables/tests/Unit/FormRendererTest.php new file mode 100644 index 0000000..b4156f4 --- /dev/null +++ b/contentful-tables/tests/Unit/FormRendererTest.php @@ -0,0 +1,41 @@ + 'Contact Us', 'submit' => 'Send' ] ); + + $this->assertStringContainsString( '
assertStringContainsString( 'name="name"', $html ); + $this->assertStringContainsString( 'name="email"', $html ); + $this->assertStringContainsString( 'name="message"', $html ); + $this->assertStringContainsString( 'Contact Us', $html ); + $this->assertStringContainsString( '>Send<', $html ); + } + + public function test_render_defaults_title_and_submit_when_absent(): void { + $html = FormRenderer::render( [] ); + + $this->assertStringContainsString( 'Contact Us', $html ); + $this->assertStringContainsString( '>Send<', $html ); + } + + public function test_render_applies_custom_class(): void { + $html = FormRenderer::render( [ 'class' => 'my-form' ] ); + + $this->assertStringContainsString( 'contentful-form-container my-form', $html ); + } +} diff --git a/contentful-tables/tests/Unit/HelpersTest.php b/contentful-tables/tests/Unit/HelpersTest.php new file mode 100644 index 0000000..2001a23 --- /dev/null +++ b/contentful-tables/tests/Unit/HelpersTest.php @@ -0,0 +1,122 @@ +assertSame( 'Provider Name', Helpers::format_header_label( 'provider_name' ) ); + } + + public function test_format_header_label_strips_trailing_string_suffix(): void { + $this->assertSame( 'Room', Helpers::format_header_label( 'room_string' ) ); + } + + public function test_format_header_label_replaces_hyphens(): void { + $this->assertSame( 'Base Pricing', Helpers::format_header_label( 'base-pricing' ) ); + } + + public function test_resolve_title_placeholders_returns_unchanged_without_placeholder(): void { + $this->assertSame( 'Plain title', Helpers::resolve_title_placeholders( 'Plain title' ) ); + } + + public function test_resolve_title_placeholders_replaces_city_state(): void { + $post_id = static::factory()->post->create( [ 'post_name' => 'birmingham-al' ] ); + $GLOBALS['post'] = \get_post( $post_id ); + + $result = Helpers::resolve_title_placeholders( 'Listings in [city-state]' ); + + $this->assertSame( 'Listings in Birmingham, AL', $result ); + } + + public function test_resolve_title_placeholders_lowercase_modifier(): void { + $post_id = static::factory()->post->create( [ 'post_name' => 'birmingham-al' ] ); + $GLOBALS['post'] = \get_post( $post_id ); + + $result = Helpers::resolve_title_placeholders( 'Listings in [ city-state ] lowercase' ); + + $this->assertSame( 'Listings in birmingham, al', $result ); + } + + public function test_resolve_title_placeholders_without_state_suffix(): void { + $post_id = static::factory()->post->create( [ 'post_name' => 'nationwide' ] ); + $GLOBALS['post'] = \get_post( $post_id ); + + $result = Helpers::resolve_title_placeholders( '[city-state]' ); + + $this->assertSame( 'Nationwide', $result ); + } + + public function test_resolve_key_from_heading_exact_match(): void { + $result = Helpers::resolve_key_from_heading( 'food', [ 'food', 'agency' ] ); + + $this->assertSame( 'food', $result ); + } + + public function test_resolve_key_from_heading_word_match(): void { + $result = Helpers::resolve_key_from_heading( 'best food places', [ 'food' ] ); + + $this->assertSame( 'food', $result ); + } + + public function test_resolve_key_from_heading_starts_with_match(): void { + $result = Helpers::resolve_key_from_heading( 'foodtrucks', [ 'food' ] ); + + $this->assertSame( 'food', $result ); + } + + public function test_resolve_key_from_heading_returns_null_when_no_match(): void { + $result = Helpers::resolve_key_from_heading( 'unrelated', [ 'food', 'agency' ] ); + + $this->assertNull( $result ); + } + + public function test_resolve_key_from_heading_returns_heading_when_no_key_values(): void { + $result = Helpers::resolve_key_from_heading( 'anything', [] ); + + $this->assertSame( 'anything', $result ); + } + + public function test_filter_rows_by_key_single_value(): void { + $rows = [ [ 'food', 'A' ], [ 'agency', 'B' ] ]; + + $filtered = Helpers::filter_rows_by_key( $rows, 0, 'food', [ 'food', 'agency' ] ); + + $this->assertSame( [ [ 'food', 'A' ] ], $filtered ); + } + + public function test_filter_rows_by_key_multiple_values(): void { + $rows = [ [ 'food', 'A' ], [ 'agency', 'B' ], [ 'other', 'C' ] ]; + + $filtered = Helpers::filter_rows_by_key( $rows, 0, 'food,agency', [ 'food', 'agency', 'other' ] ); + + $this->assertCount( 2, $filtered ); + } + + public function test_filter_rows_by_key_returns_all_rows_when_filter_empty(): void { + $rows = [ [ 'food', 'A' ], [ 'agency', 'B' ] ]; + + $filtered = Helpers::filter_rows_by_key( $rows, 0, '', [ 'food', 'agency' ] ); + + $this->assertSame( $rows, $filtered ); + } + + public function test_filter_rows_by_key_returns_all_rows_when_key_col_negative(): void { + $rows = [ [ 'food', 'A' ] ]; + + $filtered = Helpers::filter_rows_by_key( $rows, -1, 'food', [ 'food' ] ); + + $this->assertSame( $rows, $filtered ); + } +} diff --git a/contentful-tables/tests/Unit/PluginTest.php b/contentful-tables/tests/Unit/PluginTest.php new file mode 100644 index 0000000..e83acb6 --- /dev/null +++ b/contentful-tables/tests/Unit/PluginTest.php @@ -0,0 +1,38 @@ +setAccessible( true ); + + $components = $reflection->invoke( Plugin::instance() ); + + $this->assertSame( + [ + TableDataLoader::class, + ShortcodeRegistrar::class, + GraphQLResolver::class, + SettingsPage::class, + ], + $components + ); + } +} diff --git a/contentful-tables/tests/Unit/SettingsPageTest.php b/contentful-tables/tests/Unit/SettingsPageTest.php new file mode 100644 index 0000000..fa3eb2a --- /dev/null +++ b/contentful-tables/tests/Unit/SettingsPageTest.php @@ -0,0 +1,71 @@ +settings_page = SettingsPage::instance(); + } + + public function test_get_priority_is_thirty(): void { + $this->assertSame( 30, $this->settings_page->get_priority() ); + } + + public function test_register_with_settings_hub_falls_back_when_hub_absent(): void { + if ( \class_exists( \SilverAssist\SettingsHub\SettingsHub::class ) ) { + $this->markTestSkipped( 'Settings Hub package is present in this test run; standalone fallback path is not reachable.' ); + } + + $this->settings_page->register_with_settings_hub(); + + $this->assertNotFalse( \has_action( 'admin_menu', [ $this->settings_page, 'add_menu' ] ) ); + } + + public function test_render_page_saves_load_css_option_on_submit(): void { + $admin_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin_id ); + + $_POST['submit'] = 'Save Changes'; + $_POST['load_css'] = '1'; + + ob_start(); + $this->settings_page->render_page(); + ob_get_clean(); + + $this->assertTrue( \get_option( 'contentful_tables_load_css' ) ); + + unset( $_POST['submit'], $_POST['load_css'] ); + } + + public function test_render_page_unsets_load_css_when_checkbox_absent_on_submit(): void { + $admin_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin_id ); + + $_POST['submit'] = 'Save Changes'; + unset( $_POST['load_css'] ); + + ob_start(); + $this->settings_page->render_page(); + ob_get_clean(); + + $this->assertFalse( \get_option( 'contentful_tables_load_css' ) ); + + unset( $_POST['submit'] ); + \update_option( 'contentful_tables_load_css', true ); + } +} diff --git a/contentful-tables/tests/Unit/ShortcodeRegistrarTest.php b/contentful-tables/tests/Unit/ShortcodeRegistrarTest.php new file mode 100644 index 0000000..6848eae --- /dev/null +++ b/contentful-tables/tests/Unit/ShortcodeRegistrarTest.php @@ -0,0 +1,121 @@ +getProperty( 'tables_data' ); + $property->setAccessible( true ); + $property->setValue( TableDataLoader::instance(), [] ); + + $this->registrar = ShortcodeRegistrar::instance(); + } + + public function test_get_priority_is_twenty(): void { + $this->assertSame( 20, $this->registrar->get_priority() ); + } + + public function test_render_table_shortcode_requires_id(): void { + $html = $this->registrar->render_table_shortcode( [] ); + + $this->assertStringContainsString( 'No table ID specified', $html ); + } + + public function test_render_table_shortcode_reports_missing_table(): void { + $html = $this->registrar->render_table_shortcode( [ 'id' => 'does-not-exist' ] ); + + $this->assertStringContainsString( 'not found', $html ); + } + + public function test_render_table_shortcode_uses_toc_renderer_for_toc_type(): void { + $reflection = new \ReflectionClass( TableDataLoader::class ); + $property = $reflection->getProperty( 'tables_data' ); + $property->setAccessible( true ); + $property->setValue( TableDataLoader::instance(), [ 'toc-1' => [ 'type' => 'tableOfContents', 'items' => [ [ 'text' => 'Section' ] ] ] ] ); + + $html = $this->registrar->render_table_shortcode( [ 'id' => 'toc-1' ] ); + + $this->assertStringContainsString( 'contentful-table-of-contents', $html ); + } + + public function test_render_table_shortcode_applies_custom_class(): void { + $reflection = new \ReflectionClass( TableDataLoader::class ); + $property = $reflection->getProperty( 'tables_data' ); + $property->setAccessible( true ); + $property->setValue( TableDataLoader::instance(), [ 't1' => [ 'rawData' => [ [ 'a' ], [ '1' ] ] ] ] ); + + $html = $this->registrar->render_table_shortcode( [ 'id' => 't1', 'class' => 'my-class' ] ); + + $this->assertStringContainsString( 'contentful-data-table my-class', $html ); + } + + public function test_render_toc_shortcode_requires_id(): void { + $html = $this->registrar->render_toc_shortcode( [] ); + + $this->assertStringContainsString( 'No TOC ID specified', $html ); + } + + public function test_render_chart_shortcode_requires_id(): void { + $html = $this->registrar->render_chart_shortcode( [] ); + + $this->assertStringContainsString( 'No ID specified', $html ); + } + + public function test_render_cards_shortcode_requires_id(): void { + $html = $this->registrar->render_cards_shortcode( [] ); + + $this->assertStringContainsString( 'No ID specified', $html ); + } + + public function test_render_form_shortcode_outputs_form(): void { + $html = $this->registrar->render_form_shortcode( [ 'title' => 'Contact', 'submit' => 'Go' ] ); + + $this->assertStringContainsString( 'assertStringContainsString( 'Contact', $html ); + $this->assertStringContainsString( 'Go', $html ); + } + + public function test_enqueue_styles_registers_file_when_asset_exists(): void { + \update_option( 'contentful_tables_load_css', true ); + + $this->registrar->enqueue_styles(); + + $this->assertTrue( \wp_style_is( 'contentful-tables', 'registered' ) || \wp_style_is( 'contentful-tables', 'enqueued' ) ); + } + + public function test_enqueue_styles_skips_when_option_disabled(): void { + // Storing the literal PHP boolean `false` via update_option() is a known + // WordPress footgun: wp_cache_get() returns `false` to signal a cache + // miss, so a genuinely-stored `false` value is indistinguishable from + // "not cached" and get_option() falls through to its $default. Storing + // '0' instead exercises the exact same `! get_option(...)` falsy check + // in enqueue_styles() without hitting that ambiguity. + \update_option( 'contentful_tables_load_css', '0' ); + \wp_dequeue_style( 'contentful-tables' ); + \wp_deregister_style( 'contentful-tables' ); + + $this->registrar->enqueue_styles(); + + $this->assertFalse( \wp_style_is( 'contentful-tables', 'enqueued' ) ); + + \update_option( 'contentful_tables_load_css', true ); + } +} diff --git a/contentful-tables/tests/Unit/TableRendererTest.php b/contentful-tables/tests/Unit/TableRendererTest.php new file mode 100644 index 0000000..9112d94 --- /dev/null +++ b/contentful-tables/tests/Unit/TableRendererTest.php @@ -0,0 +1,89 @@ + [ [ 'Name', 'Price' ], [ 'Widget', '$10' ] ] ], + 'table-1' + ); + + $this->assertStringContainsString( '', $html ); + $this->assertStringContainsString( '', $html ); + $this->assertStringContainsString( '', $html ); + } + + public function test_render_hides_key_column_from_display(): void { + $html = TableRenderer::render( + [ + 'rawData' => [ [ 'key', 'Name' ], [ 'food', 'Acme Food' ] ], + 'keyColumn' => 'key', + ], + 'table-2' + ); + + $this->assertStringNotContainsString( '', $html ); + $this->assertStringNotContainsString( '', $html ); + $this->assertStringContainsString( '', $html ); + } + + public function test_render_applies_key_filter(): void { + $html = TableRenderer::render( + [ + 'rawData' => [ [ 'key', 'Name' ], [ 'food', 'Acme Food' ], [ 'agency', 'Acme Agency' ] ], + 'keyColumn' => 'key', + 'keyValues' => [ 'food', 'agency' ], + ], + 'table-3', + 'food' + ); + + $this->assertStringContainsString( 'Acme Food', $html ); + $this->assertStringNotContainsString( 'Acme Agency', $html ); + } + + public function test_render_legacy_headers_rows_format(): void { + $html = TableRenderer::render( + [ 'headers' => [ 'A', 'B' ], 'rows' => [ [ '1', '2' ] ] ], + 'table-4' + ); + + $this->assertStringContainsString( '', $html ); + $this->assertStringContainsString( '', $html ); + } + + public function test_render_shows_placeholder_when_no_recognized_format(): void { + $html = TableRenderer::render( [], 'table-5' ); + + $this->assertStringContainsString( 'No table data available.', $html ); + } + + public function test_render_shows_title_when_no_key_filter(): void { + $html = TableRenderer::render( [ 'title' => 'My Title', 'rawData' => [ [ 'a' ], [ '1' ] ] ], 'table-6' ); + + $this->assertStringContainsString( '

My Title

', $html ); + } + + public function test_render_hides_title_when_key_filter_present(): void { + $html = TableRenderer::render( + [ 'title' => 'My Title', 'rawData' => [ [ 'a' ], [ '1' ] ] ], + 'table-7', + 'some-filter' + ); + + $this->assertStringNotContainsString( '

My Title

', $html ); + } +} diff --git a/contentful-tables/tests/Unit/TocRendererTest.php b/contentful-tables/tests/Unit/TocRendererTest.php new file mode 100644 index 0000000..318bf7c --- /dev/null +++ b/contentful-tables/tests/Unit/TocRendererTest.php @@ -0,0 +1,60 @@ + [ [ 'text' => 'Section One', 'anchor' => 'section-one' ] ] ], + 'toc-1' + ); + + $this->assertStringContainsString( '
    ', $html ); + $this->assertStringContainsString( 'href="#section-one"', $html ); + $this->assertStringContainsString( 'Section One', $html ); + } + + public function test_render_static_item_defaults_anchor_from_text(): void { + $html = TocRenderer::render( [ 'items' => [ [ 'text' => 'My Section' ] ] ], 'toc-2' ); + + $this->assertStringContainsString( 'href="#my-section"', $html ); + } + + public function test_render_dynamic_toc_when_no_items(): void { + $html = TocRenderer::render( [], 'toc-3' ); + + $this->assertStringContainsString( '
NameWidgetkeyfoodAcme FoodA1