diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0900814..0eea005 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: - { series: "8.3", patch: "8.3.36" } - { series: "8.4", patch: "8.4.27" } - { series: "8.5", patch: "8.5.12" } - wp-version: ["6.8", "latest"] + wp-version: ["6.8", "6.9", "7.0", "latest"] steps: - name: Checkout @@ -62,7 +62,7 @@ jobs: run: npm run env:start - name: Run PHPUnit - run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test" + run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test:unit" - name: Generate PHPUnit dashboard if: always() && hashFiles('tests/phpunit/test-results/junit.xml') != '' diff --git a/README.md b/README.md index ffbb062..3205aaa 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,34 @@ define( 'BEEHIIV_SSLVERIFY', false ); // optional; local private CA only Post template for API payloads uses the plugin default `post_template_id`; omit `post_template_id` from the request when unset (`Newsletter\PostSettingsBuilder`). +## WordPress VIP runtime config + +On WordPress VIP, the platform defines `VIP_BEEHIIV_CONFIG` before the plugin loads. It is a plain PHP array built from the config form in the VIP Dashboard. The plugin reads it in `Beehiiv\OAuth\Config`. The field is declared in `vip-manifest.yaml`. + +| Key | Type | Required | Effect | +| -------------- | ---- | -------- | ---------------------------------------------------------------------- | +| `api_base_url` | url | No | Overrides the beehiiv API base URL. Empty or invalid values are ignored. | + +The plugin resolves the API base URL in this order: `VIP_BEEHIIV_CONFIG['api_base_url']`, then the `BEEHIIV_API_BASE_URL` constant from `wp-config.php`, then the production default. The plugin never fatals on missing or malformed config. It falls back to the next source. + +Valid example: + +```php +define( 'VIP_BEEHIIV_CONFIG', [ + 'api_base_url' => 'https://api.example.test/v2', +] ); +``` + +Incomplete example (setup in progress, the field saved blank). The plugin ignores the blank value and uses the default API URL: + +```php +define( 'VIP_BEEHIIV_CONFIG', [ + 'api_base_url' => '', +] ); +``` + +Test fixtures for the valid, minimal, incomplete and invalid states live in `tests/phpunit/fixtures/`. + ## Linting ```bash diff --git a/composer.json b/composer.json index 60dea71..9b0199e 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,12 @@ } }, "scripts": { - "test": "phpunit -c phpunit.xml.dist" + "test": [ + "@test:unit", + "@test:e2e" + ], + "test:unit": "phpunit -c phpunit.xml.dist", + "test:e2e": "npx playwright test" }, "minimum-stability": "stable", "require": { diff --git a/includes/OAuth/Config.php b/includes/OAuth/Config.php index 15a77f5..558b3cf 100644 --- a/includes/OAuth/Config.php +++ b/includes/OAuth/Config.php @@ -37,6 +37,16 @@ final class Config { */ private const REGISTRATION_TOKEN = 'BEEHIIV_REGISTRATION_TOKEN_PLACEHOLDER'; + /** + * Constant WordPress VIP defines before the plugin loads, holding a config array. + * + * Derived from the integration slug in `vip-manifest.yaml`. Supported key: + * `api_base_url` (optional URL; empty or invalid values are ignored). + * + * @since 1.1.0 + */ + public const CONSTANT_NAME = 'VIP_BEEHIIV_CONFIG'; + /** * WP-config.php constant for registration token override. * @@ -114,9 +124,76 @@ public static function get_oauth_base_url(): string { */ public static function get_api_base_url(): string { + $vip_url = self::sanitize_vip_url( self::get_vip_config()['api_base_url'] ?? '' ); + + if ( '' !== $vip_url ) { + return $vip_url; + } + return self::get_url_constant( self::API_BASE_URL_CONST, self::DEFAULT_API_BASE_URL ); } + /** + * Config array WordPress VIP defines, or an empty array when absent or invalid. + * + * Missing or malformed config is a normal state, never an error: callers + * fall back to the wp-config constant and then the production default. + * + * @since 1.1.0 + * + * @return array + */ + public static function get_vip_config(): array { + + if ( ! defined( self::CONSTANT_NAME ) ) { + return []; + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Defined by the VIP platform. + return self::parse_vip_config( constant( self::CONSTANT_NAME ) ); + } + + /** + * Normalize a raw VIP config value to an array. + * + * @since 1.1.0 + * + * @param mixed $raw Raw value of the VIP config constant. + * + * @return array + */ + public static function parse_vip_config( $raw ): array { + + return is_array( $raw ) ? $raw : []; + } + + /** + * Return a usable http(s) URL from a VIP config value, or an empty string. + * + * A missing key, a non-string value, a blank string and a non-http(s) URL + * are all treated the same: unset. + * + * @since 1.1.0 + * + * @param mixed $value Raw config value. + * + * @return string + */ + public static function sanitize_vip_url( $value ): string { + + if ( ! is_string( $value ) ) { + return ''; + } + + $url = trim( $value ); + + if ( '' === $url || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), [ 'http', 'https' ], true ) ) { + return ''; + } + + return untrailingslashit( $url ); + } + /** * Whether outbound HTTPS requests should verify TLS certificates. * diff --git a/tests/phpunit/VipConfigTest.php b/tests/phpunit/VipConfigTest.php new file mode 100644 index 0000000..397627f --- /dev/null +++ b/tests/phpunit/VipConfigTest.php @@ -0,0 +1,109 @@ +assertSame( 'VIP_BEEHIIV_CONFIG', Config::CONSTANT_NAME ); + } + + public function test_valid_config_supplies_the_api_base_url(): void { + + $config = Config::parse_vip_config( $this->fixture( 'valid' ) ); + + $this->assertSame( 'https://api.example.test/v2', Config::sanitize_vip_url( $config['api_base_url'] ) ); + } + + public function test_minimal_config_leaves_the_api_base_url_unset(): void { + + $config = Config::parse_vip_config( $this->fixture( 'minimal' ) ); + + $this->assertSame( '', Config::sanitize_vip_url( $config['api_base_url'] ?? '' ) ); + } + + public function test_incomplete_config_is_treated_as_unset(): void { + + $config = Config::parse_vip_config( $this->fixture( 'incomplete' ) ); + + $this->assertSame( '', Config::sanitize_vip_url( $config['api_base_url'] ) ); + } + + public function test_invalid_config_is_treated_as_unset(): void { + + $config = Config::parse_vip_config( $this->fixture( 'invalid' ) ); + + $this->assertSame( '', Config::sanitize_vip_url( $config['api_base_url'] ) ); + } + + /** + * @dataProvider non_array_values + * + * @param mixed $raw Value the constant could hold. + */ + public function test_non_array_constant_value_never_fatals( $raw ): void { + + $this->assertSame( [], Config::parse_vip_config( $raw ) ); + } + + public function non_array_values(): array { + + return [ + 'null' => [ null ], + 'string' => [ 'https://example.test' ], + 'int' => [ 1 ], + 'bool' => [ false ], + ]; + } + + /** + * @dataProvider unusable_urls + * + * @param mixed $value Raw field value. + */ + public function test_unusable_url_values_are_ignored( $value ): void { + + $this->assertSame( '', Config::sanitize_vip_url( $value ) ); + } + + public function unusable_urls(): array { + + return [ + 'null' => [ null ], + 'array' => [ [ 'x' ] ], + 'blank' => [ '' ], + 'whitespace' => [ ' ' ], + 'no scheme' => [ 'api.example.test' ], + 'ftp scheme' => [ 'ftp://api.example.test' ], + 'javascript' => [ 'javascript:alert(1)' ], + ]; + } + + public function test_absent_constant_falls_back_to_the_default_api_base_url(): void { + + $this->assertFalse( defined( Config::CONSTANT_NAME ) ); + $this->assertSame( [], Config::get_vip_config() ); + $this->assertStringStartsWith( 'http', Config::get_api_base_url() ); + } +} diff --git a/tests/phpunit/fixtures/config-incomplete.php b/tests/phpunit/fixtures/config-incomplete.php new file mode 100644 index 0000000..36b2868 --- /dev/null +++ b/tests/phpunit/fixtures/config-incomplete.php @@ -0,0 +1,10 @@ + ' ', +]; diff --git a/tests/phpunit/fixtures/config-invalid.php b/tests/phpunit/fixtures/config-invalid.php new file mode 100644 index 0000000..5b53675 --- /dev/null +++ b/tests/phpunit/fixtures/config-invalid.php @@ -0,0 +1,10 @@ + [ 'not', 'a', 'string' ], +]; diff --git a/tests/phpunit/fixtures/config-minimal.php b/tests/phpunit/fixtures/config-minimal.php new file mode 100644 index 0000000..2b3b35b --- /dev/null +++ b/tests/phpunit/fixtures/config-minimal.php @@ -0,0 +1,8 @@ + 'https://api.example.test/v2/', +]; diff --git a/vip-manifest.yaml b/vip-manifest.yaml new file mode 100644 index 0000000..0438820 --- /dev/null +++ b/vip-manifest.yaml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=./vip-manifest.schema.json + +manifest_version: 2 +manifest_kind: vip-integration-handoff + +integration: + slug: beehiiv + display_name: beehiiv + summary: Publish WordPress posts as newsletters and grow your audience with beehiiv. + partner: + name: beehiiv + support_contact: https://www.beehiiv.com/support + +documentation: + public_url: https://www.beehiiv.com/support + +runtime: + wordpress_plugin: + folder: beehiiv + entry_file: beehiiv.php + php_namespace: 'Beehiiv' + scope: site + +runtime_config: + fields: + - key: api_base_url + label: API base URL + type: url + help: Base URL for the beehiiv API. Leave empty to use the default. + nullable: true + +release: + plugin_version: 1.1.0 + version_strategy: latest + migration_required: false + changelog: Adds newsletter title and subtitle, per-post publication choice, and test email send from the post editor.