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( '