Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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') != ''
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
77 changes: 77 additions & 0 deletions includes/OAuth/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, mixed>
*/
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<string, mixed>
*/
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.
*
Expand Down
109 changes: 109 additions & 0 deletions tests/phpunit/VipConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php
/**
* Coverage for the WordPress VIP runtime config reader.
*
* @package beehiiv
*/

use Beehiiv\OAuth\Config;

/**
* @covers \Beehiiv\OAuth\Config
*/
class VipConfigTest extends WP_UnitTestCase {

/**
* Load a fixture file and return the array it defines.
*
* @param string $name Fixture name without the `config-` prefix.
*
* @return array
*/
private function fixture( string $name ): array {

return require __DIR__ . '/fixtures/config-' . $name . '.php';
}

public function test_constant_name_follows_the_manifest_slug(): void {

$this->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() );
}
}
10 changes: 10 additions & 0 deletions tests/phpunit/fixtures/config-incomplete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
/**
* VIP config fixture: setup in progress, the form field saved blank.
*
* @package beehiiv
*/

return [
'api_base_url' => ' ',
];
10 changes: 10 additions & 0 deletions tests/phpunit/fixtures/config-invalid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
/**
* VIP config fixture: wrong value type and a non-http URL.
*
* @package beehiiv
*/

return [
'api_base_url' => [ 'not', 'a', 'string' ],
];
8 changes: 8 additions & 0 deletions tests/phpunit/fixtures/config-minimal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php
/**
* VIP config fixture: no keys set. No field is required, so this is complete.
*
* @package beehiiv
*/

return [];
10 changes: 10 additions & 0 deletions tests/phpunit/fixtures/config-valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
/**
* VIP config fixture: every supported key set to a valid value.
*
* @package beehiiv
*/

return [
'api_base_url' => 'https://api.example.test/v2/',
];
36 changes: 36 additions & 0 deletions vip-manifest.yaml
Original file line number Diff line number Diff line change
@@ -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.
Loading