You've already forked wp-prometheus
feat: Add comprehensive PHPUnit test suite and CI/CD test gating (v0.5.0)
189 tests across 8 test classes covering all core plugin classes: CustomMetricBuilder, StorageFactory, Authentication, DashboardProvider, RuntimeCollector, Installer, Collector, and MetricsEndpoint. Added test job to Gitea release workflow that gates build-release. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,32 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, xml, zip, intl, gettext, redis, apcu
|
||||
tools: composer:v2
|
||||
|
||||
- name: Validate composer.json
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --optimize-autoloader --no-interaction
|
||||
|
||||
- name: Run tests
|
||||
run: composer test
|
||||
|
||||
build-release:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,3 +7,6 @@ releases/*
|
||||
|
||||
# Marketing texts (not for distribution)
|
||||
MARKETING.md
|
||||
|
||||
# PHPUnit cache
|
||||
.phpunit.cache
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.5.0] - 2026-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- Comprehensive PHPUnit test suite with 189 tests and 329 assertions:
|
||||
- CustomMetricBuilderTest (35 tests) - validation, CRUD, import/export
|
||||
- AuthenticationTest (13 tests) - Bearer token, query param, header extraction
|
||||
- StorageFactoryTest (25 tests) - adapter config, env vars, connection testing
|
||||
- RuntimeCollectorTest (22 tests) - endpoint normalization, histograms, singleton
|
||||
- DashboardProviderTest (27 tests) - registration validation, path traversal security
|
||||
- InstallerTest (11 tests) - activation, deactivation, uninstall cleanup
|
||||
- CollectorTest (10 tests) - registry, metric registration, render output
|
||||
- MetricsEndpointTest (5 tests) - rewrite rules, request routing
|
||||
- Test bootstrap with WordPress function stubs and GlobalFunctionState helper
|
||||
- CI/CD test job in Gitea release workflow that gates release builds
|
||||
- php-mock/php-mock-phpunit dependency for mocking WordPress functions in namespaced code
|
||||
|
||||
### Changed
|
||||
|
||||
- Release pipeline now requires passing tests before building release packages
|
||||
|
||||
## [0.4.9] - 2026-02-26
|
||||
|
||||
### Security
|
||||
|
||||
60
CLAUDE.md
60
CLAUDE.md
@@ -209,7 +209,7 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
|
||||
```txt
|
||||
wp-prometheus/
|
||||
├── .gitea/workflows/
|
||||
│ └── release.yml # CI/CD pipeline
|
||||
│ └── release.yml # CI/CD pipeline (test + build)
|
||||
├── assets/
|
||||
│ ├── css/ # Admin/Frontend styles
|
||||
│ ├── dashboards/ # Grafana dashboard templates
|
||||
@@ -238,10 +238,28 @@ wp-prometheus/
|
||||
│ ├── Installer.php # Activation/Deactivation
|
||||
│ ├── Plugin.php # Main plugin class
|
||||
│ └── index.php
|
||||
├── tests/
|
||||
│ ├── bootstrap.php # WP constants + function stubs
|
||||
│ ├── Helpers/
|
||||
│ │ └── GlobalFunctionState.php # Controllable stub state
|
||||
│ └── Unit/
|
||||
│ ├── TestCase.php # Base class with PHPMock
|
||||
│ ├── AuthenticationTest.php
|
||||
│ ├── InstallerTest.php
|
||||
│ ├── Admin/
|
||||
│ │ └── DashboardProviderTest.php
|
||||
│ ├── Endpoint/
|
||||
│ │ └── MetricsEndpointTest.php
|
||||
│ └── Metrics/
|
||||
│ ├── CollectorTest.php
|
||||
│ ├── CustomMetricBuilderTest.php
|
||||
│ ├── RuntimeCollectorTest.php
|
||||
│ └── StorageFactoryTest.php
|
||||
├── CHANGELOG.md
|
||||
├── CLAUDE.md
|
||||
├── composer.json
|
||||
├── index.php
|
||||
├── phpunit.xml # PHPUnit 10 configuration
|
||||
├── PLAN.md
|
||||
├── README.md
|
||||
├── uninstall.php
|
||||
@@ -290,6 +308,46 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) {
|
||||
|
||||
## Session History
|
||||
|
||||
### 2026-02-26 - PHPUnit Test Suite & CI/CD Integration (v0.5.0)
|
||||
|
||||
- Created comprehensive PHPUnit test suite with 189 tests and 329 assertions
|
||||
- Added `php-mock/php-mock-phpunit:^2.10` to composer.json require-dev
|
||||
- Created test infrastructure:
|
||||
- `tests/bootstrap.php`: ~45 WordPress function stubs with `if (!function_exists())` guards
|
||||
- `tests/Helpers/GlobalFunctionState.php`: Static class for controlling stub behavior
|
||||
- `tests/Unit/TestCase.php`: Abstract base class with PHPMock trait
|
||||
- 8 test classes covering all core plugin classes:
|
||||
- `CustomMetricBuilderTest` (35 tests) - validation, CRUD, import/export
|
||||
- `StorageFactoryTest` (25 tests) - adapter config, env vars, connection tests
|
||||
- `AuthenticationTest` (13 tests) - Bearer/query auth, header extraction
|
||||
- `DashboardProviderTest` (27 tests) - registration, path traversal security
|
||||
- `RuntimeCollectorTest` (22 tests) - endpoint normalization, histograms
|
||||
- `InstallerTest` (11 tests) - activate, deactivate, uninstall cleanup
|
||||
- `CollectorTest` (10 tests) - registry, register_gauge/counter/histogram, render
|
||||
- `MetricsEndpointTest` (5 tests) - rewrite rules, request routing
|
||||
- Added `test` job to `.gitea/workflows/release.yml` that gates `build-release`
|
||||
- **Key Learning**: php-mock/php-mock-phpunit for WordPress testing without WP environment
|
||||
- Intercepts unqualified function calls in namespaced code via PHP namespace fallback
|
||||
- `$this->getFunctionMock('Namespace', 'function_name')` creates expectations
|
||||
- Does NOT work for functions called from global namespace (bootstrap stubs) or in PHP 8.4 for some edge cases
|
||||
- Solution for global-scope stubs: Make them controllable via `GlobalFunctionState::$options`
|
||||
- **Key Learning**: Testing singletons and static state
|
||||
- Use `ReflectionClass::newInstanceWithoutConstructor()` to bypass private constructors
|
||||
- Reset static `$instance` properties via `ReflectionProperty::setValue(null, null)` in tearDown
|
||||
- Always reset StorageFactory and RuntimeCollector singletons between tests
|
||||
- **Key Learning**: CI/CD pipeline structure for test gating
|
||||
- `test` job uses `composer install` (WITH dev deps) to run tests
|
||||
- `build-release` job uses `--no-dev` (unchanged) for production builds
|
||||
- `needs: test` dependency ensures failing tests block releases
|
||||
|
||||
### 2026-02-26 - Security Audit & Refactoring (v0.4.9)
|
||||
|
||||
- Fixed XSS vulnerabilities in admin.js (jQuery `.html()` → safe DOM construction)
|
||||
- Fixed insecure token generation (`Math.random()` → Web Crypto API)
|
||||
- Added 1 MB import size limit, import mode validation, removed `site_url` from exports
|
||||
- Extracted shared authentication logic and helper methods
|
||||
- Optimized WooCommerce product counting with COUNT query
|
||||
|
||||
### 2026-02-07 - Fix Early Textdomain Loading (v0.4.8)
|
||||
|
||||
- Fixed `_load_textdomain_just_in_time` warning on admin pages (WordPress 6.7+ compatibility)
|
||||
|
||||
40
README.md
40
README.md
@@ -222,6 +222,46 @@ add_action( 'wp_prometheus_register_dashboards', function( $provider ) {
|
||||
|
||||
## Development
|
||||
|
||||
### Testing
|
||||
|
||||
The plugin includes a comprehensive PHPUnit test suite with 189 tests covering all core classes.
|
||||
|
||||
```bash
|
||||
# Install dependencies (including dev)
|
||||
composer install
|
||||
|
||||
# Run the full test suite
|
||||
composer test
|
||||
```
|
||||
|
||||
#### Test Architecture
|
||||
|
||||
Tests use [php-mock/php-mock-phpunit](https://github.com/php-mock/php-mock-phpunit) to mock WordPress functions called from namespaced plugin code, and a `GlobalFunctionState` helper for controlling global-scope function stubs (authentication, options, conditionals).
|
||||
|
||||
```
|
||||
tests/
|
||||
├── bootstrap.php # WP constants + function stubs
|
||||
├── Helpers/
|
||||
│ └── GlobalFunctionState.php # Controllable state for stubs
|
||||
└── Unit/
|
||||
├── TestCase.php # Base class with PHPMock trait
|
||||
├── AuthenticationTest.php # Bearer/query auth, header extraction
|
||||
├── InstallerTest.php # Activate, deactivate, uninstall
|
||||
├── Metrics/
|
||||
│ ├── CustomMetricBuilderTest.php # Validation, CRUD, import/export
|
||||
│ ├── RuntimeCollectorTest.php # Endpoint normalization, histograms
|
||||
│ ├── StorageFactoryTest.php # Adapter config, env vars, connections
|
||||
│ └── CollectorTest.php # Registry, register_*, render
|
||||
├── Admin/
|
||||
│ └── DashboardProviderTest.php # Registration, path security
|
||||
└── Endpoint/
|
||||
└── MetricsEndpointTest.php # Rewrite rules, request routing
|
||||
```
|
||||
|
||||
#### CI/CD Integration
|
||||
|
||||
Tests run automatically in the Gitea CI/CD pipeline before release builds. A failing test suite blocks the release.
|
||||
|
||||
### Build for Release
|
||||
|
||||
```bash
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"promphp/prometheus_client_php": "^2.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-mock/php-mock-phpunit": "^2.10",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"wp-coding-standards/wpcs": "^3.0",
|
||||
|
||||
209
composer.lock
generated
209
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3cacd9c609c3c49fb4600d09a38c04be",
|
||||
"content-hash": "cfd3853b3cf76d82f972f3326e4f94d3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "magdev/wc-licensed-product-client",
|
||||
@@ -1128,6 +1128,213 @@
|
||||
},
|
||||
"time": "2022-02-21T01:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-mock/php-mock",
|
||||
"version": "2.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-mock/php-mock.git",
|
||||
"reference": "b59734f19765296bb0311942850d02288a224890"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-mock/php-mock/zipball/b59734f19765296bb0311942850d02288a224890",
|
||||
"reference": "b59734f19765296bb0311942850d02288a224890",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^5.6 || ^7.0 || ^8.0",
|
||||
"phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6"
|
||||
},
|
||||
"replace": {
|
||||
"malkusch/php-mock": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
|
||||
"squizlabs/php_codesniffer": "^3.8"
|
||||
},
|
||||
"suggest": {
|
||||
"php-mock/php-mock-phpunit": "Allows integration into PHPUnit testcase with the trait PHPMock."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"autoload.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"phpmock\\": [
|
||||
"classes/",
|
||||
"tests/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"WTFPL"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Markus Malkusch",
|
||||
"email": "markus@malkusch.de",
|
||||
"homepage": "http://markus.malkusch.de",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP-Mock can mock built-in PHP functions (e.g. time()). PHP-Mock relies on PHP's namespace fallback policy. No further extension is needed.",
|
||||
"homepage": "https://github.com/php-mock/php-mock",
|
||||
"keywords": [
|
||||
"BDD",
|
||||
"TDD",
|
||||
"function",
|
||||
"mock",
|
||||
"stub",
|
||||
"test",
|
||||
"test double",
|
||||
"testing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-mock/php-mock/issues",
|
||||
"source": "https://github.com/php-mock/php-mock/tree/2.7.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/michalbundyra",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-06T07:39:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-mock/php-mock-integration",
|
||||
"version": "3.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-mock/php-mock-integration.git",
|
||||
"reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-mock/php-mock-integration/zipball/cbbf39705ec13dece5b04133cef4e2fd3137a345",
|
||||
"reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.6",
|
||||
"php-mock/php-mock": "^2.5",
|
||||
"phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5.7.27 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || ^13"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"phpmock\\integration\\": "classes/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"WTFPL"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Markus Malkusch",
|
||||
"email": "markus@malkusch.de",
|
||||
"homepage": "http://markus.malkusch.de",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Integration package for PHP-Mock",
|
||||
"homepage": "https://github.com/php-mock/php-mock-integration",
|
||||
"keywords": [
|
||||
"BDD",
|
||||
"TDD",
|
||||
"function",
|
||||
"mock",
|
||||
"stub",
|
||||
"test",
|
||||
"test double"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-mock/php-mock-integration/issues",
|
||||
"source": "https://github.com/php-mock/php-mock-integration/tree/3.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/michalbundyra",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-06T07:44:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-mock/php-mock-phpunit",
|
||||
"version": "2.15.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-mock/php-mock-phpunit.git",
|
||||
"reference": "701df15b183f25af663af134eb71353cd838b955"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-mock/php-mock-phpunit/zipball/701df15b183f25af663af134eb71353cd838b955",
|
||||
"reference": "701df15b183f25af663af134eb71353cd838b955",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7",
|
||||
"php-mock/php-mock-integration": "^3.0",
|
||||
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10.0.17 || ^11 || ^12.0.9 || ^13"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"autoload.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"phpmock\\phpunit\\": "classes/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"WTFPL"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Markus Malkusch",
|
||||
"email": "markus@malkusch.de",
|
||||
"homepage": "http://markus.malkusch.de",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Mock built-in PHP functions (e.g. time()) with PHPUnit. This package relies on PHP's namespace fallback policy. No further extension is needed.",
|
||||
"homepage": "https://github.com/php-mock/php-mock-phpunit",
|
||||
"keywords": [
|
||||
"BDD",
|
||||
"TDD",
|
||||
"function",
|
||||
"mock",
|
||||
"phpunit",
|
||||
"stub",
|
||||
"test",
|
||||
"test double",
|
||||
"testing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-mock/php-mock-phpunit/issues",
|
||||
"source": "https://github.com/php-mock/php-mock-phpunit/tree/2.15.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/michalbundyra",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-06T09:12:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpcompatibility/php-compatibility",
|
||||
"version": "9.3.5",
|
||||
|
||||
25
phpunit.xml
Normal file
25
phpunit.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<file>src/index.php</file>
|
||||
</exclude>
|
||||
</source>
|
||||
</phpunit>
|
||||
51
tests/Helpers/GlobalFunctionState.php
Normal file
51
tests/Helpers/GlobalFunctionState.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Helpers;
|
||||
|
||||
/**
|
||||
* Controllable state for global WordPress function stubs.
|
||||
*
|
||||
* Used by the bootstrap's global get_option() stub so that tests
|
||||
* for global-scope functions (e.g., wp_prometheus_authenticate_request)
|
||||
* can control return values without php-mock (which requires namespaces).
|
||||
*/
|
||||
class GlobalFunctionState
|
||||
{
|
||||
/** @var array<string, mixed> Simulated WordPress options. */
|
||||
public static array $options = [];
|
||||
|
||||
/** @var array<string, int> Track function call counts. */
|
||||
public static array $callCounts = [];
|
||||
|
||||
/** @var array<string, list<mixed>> Track arguments passed to functions. */
|
||||
public static array $callArgs = [];
|
||||
|
||||
/**
|
||||
* Reset all state. Call in setUp()/tearDown().
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$options = [];
|
||||
self::$callCounts = [];
|
||||
self::$callArgs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a function call for later assertions.
|
||||
*/
|
||||
public static function recordCall(string $function, mixed ...$args): void
|
||||
{
|
||||
self::$callCounts[$function] = (self::$callCounts[$function] ?? 0) + 1;
|
||||
self::$callArgs[$function][] = $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call count for a function.
|
||||
*/
|
||||
public static function getCallCount(string $function): int
|
||||
{
|
||||
return self::$callCounts[$function] ?? 0;
|
||||
}
|
||||
}
|
||||
376
tests/Unit/Admin/DashboardProviderTest.php
Normal file
376
tests/Unit/Admin/DashboardProviderTest.php
Normal file
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit\Admin;
|
||||
|
||||
use Magdev\WpPrometheus\Admin\DashboardProvider;
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use Magdev\WpPrometheus\Tests\Unit\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
#[CoversClass(DashboardProvider::class)]
|
||||
class DashboardProviderTest extends TestCase
|
||||
{
|
||||
private DashboardProvider $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->provider = new DashboardProvider();
|
||||
}
|
||||
|
||||
// ── register_dashboard() - Validation ────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function register_with_inline_json_succeeds(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('my-dashboard', [
|
||||
'title' => 'My Dashboard',
|
||||
'json' => '{"panels":[]}',
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_rejects_empty_slug(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('', [
|
||||
'title' => 'Test',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_rejects_invalid_slug_characters(): void
|
||||
{
|
||||
// sanitize_key removes all non [a-z0-9_-] characters
|
||||
// A slug like '!@#' becomes '' after sanitize_key
|
||||
$result = $this->provider->register_dashboard('!@#', [
|
||||
'title' => 'Test',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_rejects_duplicate_builtin_slug(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('wordpress-overview', [
|
||||
'title' => 'Override Built-in',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_rejects_duplicate_registered_slug(): void
|
||||
{
|
||||
$this->provider->register_dashboard('my-dashboard', [
|
||||
'title' => 'First',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$result = $this->provider->register_dashboard('my-dashboard', [
|
||||
'title' => 'Second',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_requires_title(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('no-title', [
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_requires_file_or_json(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('no-content', [
|
||||
'title' => 'No Content',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_rejects_both_file_and_json(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(true);
|
||||
|
||||
$isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable');
|
||||
$isReadable->expects($this->any())->willReturn(true);
|
||||
|
||||
$realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath');
|
||||
$realpath->expects($this->any())->willReturnArgument(0);
|
||||
|
||||
$result = $this->provider->register_dashboard('both', [
|
||||
'title' => 'Both',
|
||||
'file' => '/tmp/wordpress/wp-content/plugins/test/dashboard.json',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_file_requires_absolute_path(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('relative', [
|
||||
'title' => 'Relative Path',
|
||||
'file' => 'relative/path/dashboard.json',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_file_must_exist(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(false);
|
||||
|
||||
$result = $this->provider->register_dashboard('missing-file', [
|
||||
'title' => 'Missing File',
|
||||
'file' => '/tmp/wordpress/wp-content/plugins/test/nonexistent.json',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_file_rejects_path_traversal(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(true);
|
||||
|
||||
$isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable');
|
||||
$isReadable->expects($this->any())->willReturn(true);
|
||||
|
||||
$realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath');
|
||||
$realpath->expects($this->any())->willReturnArgument(0);
|
||||
|
||||
$result = $this->provider->register_dashboard('evil', [
|
||||
'title' => 'Evil Dashboard',
|
||||
'file' => '/etc/passwd',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_file_accepts_path_under_wp_content(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(true);
|
||||
|
||||
$isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable');
|
||||
$isReadable->expects($this->any())->willReturn(true);
|
||||
|
||||
$realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath');
|
||||
$realpath->expects($this->any())->willReturnArgument(0);
|
||||
|
||||
$result = $this->provider->register_dashboard('valid-file', [
|
||||
'title' => 'Valid File Dashboard',
|
||||
'file' => '/tmp/wordpress/wp-content/plugins/my-plugin/dashboard.json',
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_rejects_invalid_inline_json(): void
|
||||
{
|
||||
$result = $this->provider->register_dashboard('bad-json', [
|
||||
'title' => 'Bad JSON',
|
||||
'json' => '{invalid json',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_sets_source_to_third_party(): void
|
||||
{
|
||||
$this->provider->register_dashboard('ext-dashboard', [
|
||||
'title' => 'Extension',
|
||||
'json' => '{}',
|
||||
'plugin' => 'My Plugin',
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->provider->is_third_party('ext-dashboard'));
|
||||
}
|
||||
|
||||
// ── get_available() ──────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_available_includes_builtin_dashboards(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(true);
|
||||
|
||||
$available = $this->provider->get_available();
|
||||
|
||||
$this->assertArrayHasKey('wordpress-overview', $available);
|
||||
$this->assertArrayHasKey('wordpress-runtime', $available);
|
||||
$this->assertArrayHasKey('wordpress-woocommerce', $available);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_available_includes_registered_dashboards(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(true);
|
||||
|
||||
$this->provider->register_dashboard('custom-dash', [
|
||||
'title' => 'Custom',
|
||||
'json' => '{"panels":[]}',
|
||||
]);
|
||||
|
||||
$available = $this->provider->get_available();
|
||||
$this->assertArrayHasKey('custom-dash', $available);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_available_excludes_builtin_with_missing_file(): void
|
||||
{
|
||||
$fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists');
|
||||
$fileExists->expects($this->any())->willReturn(false);
|
||||
|
||||
$available = $this->provider->get_available();
|
||||
|
||||
// Built-in dashboards should be excluded (files don't exist).
|
||||
$this->assertArrayNotHasKey('wordpress-overview', $available);
|
||||
}
|
||||
|
||||
// ── is_third_party() ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function builtin_dashboard_is_not_third_party(): void
|
||||
{
|
||||
$this->assertFalse($this->provider->is_third_party('wordpress-overview'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function registered_dashboard_is_third_party(): void
|
||||
{
|
||||
$this->provider->register_dashboard('ext-dash', [
|
||||
'title' => 'Extension Dashboard',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->provider->is_third_party('ext-dash'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unknown_slug_is_not_third_party(): void
|
||||
{
|
||||
$this->assertFalse($this->provider->is_third_party('nonexistent'));
|
||||
}
|
||||
|
||||
// ── get_plugin_name() ────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_plugin_name_returns_name_for_registered(): void
|
||||
{
|
||||
$this->provider->register_dashboard('ext-dash', [
|
||||
'title' => 'Extension',
|
||||
'json' => '{}',
|
||||
'plugin' => 'My Awesome Plugin',
|
||||
]);
|
||||
|
||||
$this->assertSame('My Awesome Plugin', $this->provider->get_plugin_name('ext-dash'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_plugin_name_returns_null_for_builtin(): void
|
||||
{
|
||||
$this->assertNull($this->provider->get_plugin_name('wordpress-overview'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_plugin_name_returns_null_when_empty(): void
|
||||
{
|
||||
$this->provider->register_dashboard('no-plugin', [
|
||||
'title' => 'No Plugin',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertNull($this->provider->get_plugin_name('no-plugin'));
|
||||
}
|
||||
|
||||
// ── get_filename() ───────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_filename_returns_file_for_builtin(): void
|
||||
{
|
||||
$filename = $this->provider->get_filename('wordpress-overview');
|
||||
$this->assertSame('wordpress-overview.json', $filename);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_filename_returns_slug_json_for_inline(): void
|
||||
{
|
||||
$this->provider->register_dashboard('my-dash', [
|
||||
'title' => 'My Dashboard',
|
||||
'json' => '{}',
|
||||
]);
|
||||
|
||||
$this->assertSame('my-dash.json', $this->provider->get_filename('my-dash'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_filename_returns_null_for_unknown(): void
|
||||
{
|
||||
$this->assertNull($this->provider->get_filename('nonexistent'));
|
||||
}
|
||||
|
||||
// ── get_metadata() ───────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_metadata_returns_builtin_data(): void
|
||||
{
|
||||
$metadata = $this->provider->get_metadata('wordpress-overview');
|
||||
|
||||
$this->assertIsArray($metadata);
|
||||
$this->assertSame('WordPress Overview', $metadata['title']);
|
||||
$this->assertSame('builtin', $metadata['source']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_metadata_returns_registered_data(): void
|
||||
{
|
||||
$this->provider->register_dashboard('ext-dash', [
|
||||
'title' => 'Extension',
|
||||
'description' => 'A test dashboard',
|
||||
'json' => '{}',
|
||||
'plugin' => 'TestPlugin',
|
||||
]);
|
||||
|
||||
$metadata = $this->provider->get_metadata('ext-dash');
|
||||
|
||||
$this->assertIsArray($metadata);
|
||||
$this->assertSame('Extension', $metadata['title']);
|
||||
$this->assertSame('third-party', $metadata['source']);
|
||||
$this->assertSame('TestPlugin', $metadata['plugin']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_metadata_returns_null_for_unknown(): void
|
||||
{
|
||||
$this->assertNull($this->provider->get_metadata('nonexistent'));
|
||||
}
|
||||
}
|
||||
148
tests/Unit/AuthenticationTest.php
Normal file
148
tests/Unit/AuthenticationTest.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit;
|
||||
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use PHPUnit\Framework\Attributes\CoversFunction;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
#[CoversFunction('wp_prometheus_authenticate_request')]
|
||||
#[CoversFunction('wp_prometheus_get_authorization_header')]
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
private array $originalServer = [];
|
||||
private array $originalGet = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->originalServer = $_SERVER;
|
||||
$this->originalGet = $_GET;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SERVER = $this->originalServer;
|
||||
$_GET = $this->originalGet;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── wp_prometheus_authenticate_request() ─────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function returns_false_when_no_token_configured(): void
|
||||
{
|
||||
// No auth token in options → deny all.
|
||||
$this->assertFalse(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returns_false_when_token_is_empty_string(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = '';
|
||||
$this->assertFalse(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function bearer_token_authenticates_successfully(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token-123';
|
||||
|
||||
$this->assertTrue(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function bearer_token_fails_with_wrong_token(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer wrong-token';
|
||||
|
||||
$this->assertFalse(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function bearer_prefix_is_case_insensitive(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'BEARER secret-token-123';
|
||||
|
||||
$this->assertTrue(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function query_parameter_authenticates_successfully(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
|
||||
$_GET['token'] = 'secret-token-123';
|
||||
|
||||
$this->assertTrue(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function query_parameter_fails_with_wrong_token(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
|
||||
$_GET['token'] = 'wrong-token';
|
||||
|
||||
$this->assertFalse(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returns_false_when_no_auth_provided(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123';
|
||||
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
|
||||
unset($_GET['token']);
|
||||
|
||||
$this->assertFalse(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function bearer_takes_precedence_over_query_parameter(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'correct-token';
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer correct-token';
|
||||
$_GET['token'] = 'wrong-token';
|
||||
|
||||
$this->assertTrue(wp_prometheus_authenticate_request());
|
||||
}
|
||||
|
||||
// ── wp_prometheus_get_authorization_header() ─────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_authorization_header_from_http_authorization(): void
|
||||
{
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer my-token';
|
||||
|
||||
$this->assertSame('Bearer my-token', wp_prometheus_get_authorization_header());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_authorization_header_from_redirect(): void
|
||||
{
|
||||
unset($_SERVER['HTTP_AUTHORIZATION']);
|
||||
$_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer redirect-token';
|
||||
|
||||
$this->assertSame('Bearer redirect-token', wp_prometheus_get_authorization_header());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_authorization_header_returns_empty_when_absent(): void
|
||||
{
|
||||
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
|
||||
|
||||
$this->assertSame('', wp_prometheus_get_authorization_header());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function http_authorization_takes_precedence_over_redirect(): void
|
||||
{
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer primary';
|
||||
$_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer redirect';
|
||||
|
||||
$this->assertSame('Bearer primary', wp_prometheus_get_authorization_header());
|
||||
}
|
||||
}
|
||||
102
tests/Unit/Endpoint/MetricsEndpointTest.php
Normal file
102
tests/Unit/Endpoint/MetricsEndpointTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit\Endpoint;
|
||||
|
||||
use Magdev\WpPrometheus\Endpoint\MetricsEndpoint;
|
||||
use Magdev\WpPrometheus\Metrics\Collector;
|
||||
use Magdev\WpPrometheus\Metrics\StorageFactory;
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use Magdev\WpPrometheus\Tests\Unit\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
#[CoversClass(MetricsEndpoint::class)]
|
||||
class MetricsEndpointTest extends TestCase
|
||||
{
|
||||
private Collector $collector;
|
||||
private MetricsEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->resetStorageFactory();
|
||||
$this->collector = new Collector();
|
||||
$this->endpoint = new MetricsEndpoint($this->collector);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->resetStorageFactory();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function constructor_accepts_collector(): void
|
||||
{
|
||||
$this->assertInstanceOf(MetricsEndpoint::class, $this->endpoint);
|
||||
}
|
||||
|
||||
// ── register_endpoint() ───────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function register_endpoint_adds_rewrite_rule(): void
|
||||
{
|
||||
$this->endpoint->register_endpoint();
|
||||
|
||||
$this->assertSame(1, GlobalFunctionState::getCallCount('add_rewrite_rule'));
|
||||
$args = GlobalFunctionState::$callArgs['add_rewrite_rule'][0];
|
||||
$this->assertSame('^metrics/?$', $args[0]);
|
||||
$this->assertSame('index.php?wp_prometheus_metrics=1', $args[1]);
|
||||
$this->assertSame('top', $args[2]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_endpoint_adds_rewrite_tag(): void
|
||||
{
|
||||
$this->endpoint->register_endpoint();
|
||||
|
||||
$this->assertSame(1, GlobalFunctionState::getCallCount('add_rewrite_tag'));
|
||||
$args = GlobalFunctionState::$callArgs['add_rewrite_tag'][0];
|
||||
$this->assertSame('%wp_prometheus_metrics%', $args[0]);
|
||||
$this->assertSame('([^&]+)', $args[1]);
|
||||
}
|
||||
|
||||
// ── handle_request() ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function handle_request_returns_early_when_no_query_var(): void
|
||||
{
|
||||
$wp = new \WP();
|
||||
$wp->query_vars = [];
|
||||
|
||||
// Should return without calling exit or outputting anything.
|
||||
$this->endpoint->handle_request($wp);
|
||||
|
||||
// If we reach this assertion, handle_request returned early (no exit).
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handle_request_returns_early_when_query_var_empty(): void
|
||||
{
|
||||
$wp = new \WP();
|
||||
$wp->query_vars = ['wp_prometheus_metrics' => ''];
|
||||
|
||||
$this->endpoint->handle_request($wp);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
private function resetStorageFactory(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(StorageFactory::class);
|
||||
$property = $reflection->getProperty('instance');
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
}
|
||||
189
tests/Unit/InstallerTest.php
Normal file
189
tests/Unit/InstallerTest.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit;
|
||||
|
||||
use Magdev\WpPrometheus\Installer;
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
#[CoversClass(Installer::class)]
|
||||
class InstallerTest extends TestCase
|
||||
{
|
||||
// ── activate() ───────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function activate_stores_activation_time(): void
|
||||
{
|
||||
Installer::activate();
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'wp_prometheus_activated',
|
||||
GlobalFunctionState::$options
|
||||
);
|
||||
$this->assertIsInt(GlobalFunctionState::$options['wp_prometheus_activated']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activate_flushes_rewrite_rules(): void
|
||||
{
|
||||
Installer::activate();
|
||||
|
||||
$this->assertGreaterThanOrEqual(
|
||||
1,
|
||||
GlobalFunctionState::getCallCount('flush_rewrite_rules')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activate_generates_auth_token_when_not_set(): void
|
||||
{
|
||||
Installer::activate();
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'wp_prometheus_auth_token',
|
||||
GlobalFunctionState::$options
|
||||
);
|
||||
$this->assertNotEmpty(GlobalFunctionState::$options['wp_prometheus_auth_token']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activate_preserves_existing_auth_token(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'existing-token';
|
||||
|
||||
Installer::activate();
|
||||
|
||||
$this->assertSame(
|
||||
'existing-token',
|
||||
GlobalFunctionState::$options['wp_prometheus_auth_token']
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activate_enables_default_metrics(): void
|
||||
{
|
||||
Installer::activate();
|
||||
|
||||
$this->assertSame(
|
||||
1,
|
||||
GlobalFunctionState::$options['wp_prometheus_enable_default_metrics']
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activate_sets_default_enabled_metrics_list(): void
|
||||
{
|
||||
Installer::activate();
|
||||
|
||||
$enabled = GlobalFunctionState::$options['wp_prometheus_enabled_metrics'];
|
||||
$this->assertIsArray($enabled);
|
||||
$this->assertContains('wordpress_info', $enabled);
|
||||
$this->assertContains('wordpress_users_total', $enabled);
|
||||
$this->assertContains('wordpress_posts_total', $enabled);
|
||||
$this->assertContains('wordpress_comments_total', $enabled);
|
||||
$this->assertContains('wordpress_plugins_total', $enabled);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activate_preserves_existing_enabled_metrics(): void
|
||||
{
|
||||
$custom = ['wordpress_info', 'wordpress_users_total'];
|
||||
GlobalFunctionState::$options['wp_prometheus_enabled_metrics'] = $custom;
|
||||
|
||||
Installer::activate();
|
||||
|
||||
$this->assertSame(
|
||||
$custom,
|
||||
GlobalFunctionState::$options['wp_prometheus_enabled_metrics']
|
||||
);
|
||||
}
|
||||
|
||||
// ── deactivate() ─────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function deactivate_flushes_rewrite_rules(): void
|
||||
{
|
||||
Installer::deactivate();
|
||||
|
||||
$this->assertGreaterThanOrEqual(
|
||||
1,
|
||||
GlobalFunctionState::getCallCount('flush_rewrite_rules')
|
||||
);
|
||||
}
|
||||
|
||||
// ── uninstall() ──────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function uninstall_removes_all_plugin_options(): void
|
||||
{
|
||||
// Set up options that should be cleaned.
|
||||
$expected_options = [
|
||||
'wp_prometheus_activated',
|
||||
'wp_prometheus_license_key',
|
||||
'wp_prometheus_license_server_url',
|
||||
'wp_prometheus_license_server_secret',
|
||||
'wp_prometheus_license_status',
|
||||
'wp_prometheus_license_data',
|
||||
'wp_prometheus_license_last_check',
|
||||
'wp_prometheus_auth_token',
|
||||
'wp_prometheus_enable_default_metrics',
|
||||
'wp_prometheus_enabled_metrics',
|
||||
'wp_prometheus_runtime_metrics',
|
||||
'wp_prometheus_custom_metrics',
|
||||
'wp_prometheus_isolated_mode',
|
||||
'wp_prometheus_storage_adapter',
|
||||
'wp_prometheus_redis_host',
|
||||
'wp_prometheus_redis_port',
|
||||
'wp_prometheus_redis_password',
|
||||
'wp_prometheus_redis_database',
|
||||
'wp_prometheus_redis_prefix',
|
||||
'wp_prometheus_apcu_prefix',
|
||||
];
|
||||
|
||||
foreach ($expected_options as $option) {
|
||||
GlobalFunctionState::$options[$option] = 'test_value';
|
||||
}
|
||||
|
||||
Installer::uninstall();
|
||||
|
||||
// Verify all options were deleted.
|
||||
foreach ($expected_options as $option) {
|
||||
$this->assertArrayNotHasKey(
|
||||
$option,
|
||||
GlobalFunctionState::$options,
|
||||
"Option '$option' was not deleted during uninstall"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uninstall_removes_license_transient(): void
|
||||
{
|
||||
Installer::uninstall();
|
||||
|
||||
$this->assertGreaterThanOrEqual(
|
||||
1,
|
||||
GlobalFunctionState::getCallCount('delete_transient')
|
||||
);
|
||||
|
||||
// Verify the specific transient was targeted.
|
||||
$args = GlobalFunctionState::$callArgs['delete_transient'] ?? [];
|
||||
$transientNames = array_column($args, 0);
|
||||
$this->assertContains('wp_prometheus_license_check', $transientNames);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uninstall_delete_option_call_count_matches(): void
|
||||
{
|
||||
Installer::uninstall();
|
||||
|
||||
// Should call delete_option for each option in the list (20 options).
|
||||
$this->assertSame(
|
||||
20,
|
||||
GlobalFunctionState::getCallCount('delete_option')
|
||||
);
|
||||
}
|
||||
}
|
||||
155
tests/Unit/Metrics/CollectorTest.php
Normal file
155
tests/Unit/Metrics/CollectorTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
|
||||
|
||||
use Magdev\WpPrometheus\Metrics\Collector;
|
||||
use Magdev\WpPrometheus\Metrics\RuntimeCollector;
|
||||
use Magdev\WpPrometheus\Metrics\StorageFactory;
|
||||
use Magdev\WpPrometheus\Tests\Unit\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Counter;
|
||||
use Prometheus\Gauge;
|
||||
use Prometheus\Histogram;
|
||||
|
||||
#[CoversClass(Collector::class)]
|
||||
class CollectorTest extends TestCase
|
||||
{
|
||||
private Collector $collector;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->resetStorageFactory();
|
||||
$this->collector = new Collector();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->resetRuntimeCollectorSingleton();
|
||||
$this->resetStorageFactory();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Constructor & Basic Properties ─────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function constructor_creates_registry(): void
|
||||
{
|
||||
$this->assertInstanceOf(CollectorRegistry::class, $this->collector->get_registry());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_namespace_returns_wordpress(): void
|
||||
{
|
||||
$this->assertSame('wordpress', $this->collector->get_namespace());
|
||||
}
|
||||
|
||||
// ── register_gauge() ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function register_gauge_returns_gauge_instance(): void
|
||||
{
|
||||
$gauge = $this->collector->register_gauge('test_metric', 'A test gauge');
|
||||
$this->assertInstanceOf(Gauge::class, $gauge);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_gauge_with_labels(): void
|
||||
{
|
||||
$gauge = $this->collector->register_gauge(
|
||||
'labeled_metric',
|
||||
'A labeled gauge',
|
||||
['label1', 'label2']
|
||||
);
|
||||
$this->assertInstanceOf(Gauge::class, $gauge);
|
||||
}
|
||||
|
||||
// ── register_counter() ────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function register_counter_returns_counter_instance(): void
|
||||
{
|
||||
$counter = $this->collector->register_counter('test_counter', 'A test counter');
|
||||
$this->assertInstanceOf(Counter::class, $counter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_counter_with_labels(): void
|
||||
{
|
||||
$counter = $this->collector->register_counter(
|
||||
'labeled_counter',
|
||||
'A labeled counter',
|
||||
['method', 'status']
|
||||
);
|
||||
$this->assertInstanceOf(Counter::class, $counter);
|
||||
}
|
||||
|
||||
// ── register_histogram() ──────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function register_histogram_returns_histogram_instance(): void
|
||||
{
|
||||
$histogram = $this->collector->register_histogram('test_histogram', 'A test histogram');
|
||||
$this->assertInstanceOf(Histogram::class, $histogram);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function register_histogram_with_custom_buckets(): void
|
||||
{
|
||||
$buckets = [0.1, 0.5, 1.0, 5.0];
|
||||
$histogram = $this->collector->register_histogram(
|
||||
'custom_buckets_hist',
|
||||
'A histogram with custom buckets',
|
||||
['label1'],
|
||||
$buckets
|
||||
);
|
||||
$this->assertInstanceOf(Histogram::class, $histogram);
|
||||
}
|
||||
|
||||
// ── render() ──────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function render_returns_string(): void
|
||||
{
|
||||
$getOption = $this->getFunctionMock('Magdev\\WpPrometheus\\Metrics', 'get_option');
|
||||
$getOption->expects($this->any())->willReturn([]);
|
||||
|
||||
$output = $this->collector->render();
|
||||
$this->assertIsString($output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function render_includes_registered_gauge_value(): void
|
||||
{
|
||||
$getOption = $this->getFunctionMock('Magdev\\WpPrometheus\\Metrics', 'get_option');
|
||||
$getOption->expects($this->any())->willReturn([]);
|
||||
|
||||
$gauge = $this->collector->register_gauge('test_render_metric', 'Test metric for render');
|
||||
$gauge->set(42, []);
|
||||
|
||||
$output = $this->collector->render();
|
||||
|
||||
$this->assertStringContainsString('wordpress_test_render_metric', $output);
|
||||
$this->assertStringContainsString('42', $output);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
private function resetRuntimeCollectorSingleton(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(RuntimeCollector::class);
|
||||
$property = $reflection->getProperty('instance');
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
|
||||
private function resetStorageFactory(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(StorageFactory::class);
|
||||
$property = $reflection->getProperty('instance');
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
}
|
||||
655
tests/Unit/Metrics/CustomMetricBuilderTest.php
Normal file
655
tests/Unit/Metrics/CustomMetricBuilderTest.php
Normal file
@@ -0,0 +1,655 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
|
||||
|
||||
use Magdev\WpPrometheus\Metrics\CustomMetricBuilder;
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use Magdev\WpPrometheus\Tests\Unit\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
#[CoversClass(CustomMetricBuilder::class)]
|
||||
class CustomMetricBuilderTest extends TestCase
|
||||
{
|
||||
private CustomMetricBuilder $builder;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->builder = new CustomMetricBuilder();
|
||||
}
|
||||
|
||||
// ── validate_name() ──────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validMetricNamesProvider')]
|
||||
public function validate_name_accepts_valid_names(string $name): void
|
||||
{
|
||||
$this->assertTrue($this->builder->validate_name($name));
|
||||
}
|
||||
|
||||
public static function validMetricNamesProvider(): array
|
||||
{
|
||||
return [
|
||||
'simple' => ['my_metric'],
|
||||
'with_colon' => ['my:metric'],
|
||||
'starts_with_underscore' => ['_private_metric'],
|
||||
'starts_with_colon' => [':special_metric'],
|
||||
'uppercase' => ['MY_METRIC'],
|
||||
'mixed_case' => ['myMetric123'],
|
||||
'single_letter' => ['m'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidMetricNamesProvider')]
|
||||
public function validate_name_rejects_invalid_names(string $name): void
|
||||
{
|
||||
$this->assertFalse($this->builder->validate_name($name));
|
||||
}
|
||||
|
||||
public static function invalidMetricNamesProvider(): array
|
||||
{
|
||||
return [
|
||||
'starts_with_digit' => ['0metric'],
|
||||
'contains_dash' => ['my-metric'],
|
||||
'contains_space' => ['my metric'],
|
||||
'contains_dot' => ['my.metric'],
|
||||
'empty_string' => [''],
|
||||
'special_chars' => ['metric@name'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── validate_label_name() ────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validLabelNamesProvider')]
|
||||
public function validate_label_name_accepts_valid_names(string $name): void
|
||||
{
|
||||
$this->assertTrue($this->builder->validate_label_name($name));
|
||||
}
|
||||
|
||||
public static function validLabelNamesProvider(): array
|
||||
{
|
||||
return [
|
||||
'simple' => ['status'],
|
||||
'with_underscore' => ['http_method'],
|
||||
'starts_with_underscore' => ['_internal'],
|
||||
'uppercase' => ['METHOD'],
|
||||
'alphanumeric' => ['label1'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidLabelNamesProvider')]
|
||||
public function validate_label_name_rejects_invalid_names(string $name): void
|
||||
{
|
||||
$this->assertFalse($this->builder->validate_label_name($name));
|
||||
}
|
||||
|
||||
public static function invalidLabelNamesProvider(): array
|
||||
{
|
||||
return [
|
||||
'double_underscore_prefix' => ['__reserved'],
|
||||
'starts_with_digit' => ['1label'],
|
||||
'contains_colon' => ['label:name'],
|
||||
'contains_dash' => ['label-name'],
|
||||
'empty_string' => [''],
|
||||
'contains_space' => ['label name'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── validate() ───────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function validate_returns_empty_for_valid_metric(): void
|
||||
{
|
||||
$errors = $this->builder->validate($this->validMetric());
|
||||
$this->assertEmpty($errors);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_requires_name(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['name'] = '';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('name is required', $errors[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_rejects_invalid_metric_name(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['name'] = '0invalid';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('must start with', $errors[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('reservedPrefixProvider')]
|
||||
public function validate_rejects_reserved_prefix(string $prefix): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['name'] = $prefix . 'test';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('reserved prefix', implode(' ', $errors));
|
||||
}
|
||||
|
||||
public static function reservedPrefixProvider(): array
|
||||
{
|
||||
return [
|
||||
'wordpress_' => ['wordpress_'],
|
||||
'go_' => ['go_'],
|
||||
'process_' => ['process_'],
|
||||
'promhttp_' => ['promhttp_'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_detects_duplicate_name(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'existing-id' => [
|
||||
'id' => 'existing-id',
|
||||
'name' => 'custom_existing',
|
||||
'help' => 'test',
|
||||
],
|
||||
];
|
||||
|
||||
$metric = $this->validMetric();
|
||||
$metric['name'] = 'custom_existing';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('already exists', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_allows_same_name_when_editing(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'my-id' => [
|
||||
'id' => 'my-id',
|
||||
'name' => 'custom_existing',
|
||||
'help' => 'test',
|
||||
],
|
||||
];
|
||||
|
||||
$metric = $this->validMetric();
|
||||
$metric['id'] = 'my-id';
|
||||
$metric['name'] = 'custom_existing';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertEmpty($errors);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_requires_help_text(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['help'] = '';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Help text is required', $errors[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_requires_valid_type(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['type'] = 'counter';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Invalid metric type', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_rejects_too_many_labels(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['labels'] = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Maximum', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_rejects_invalid_label_names_in_array(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['labels'] = ['valid', '__reserved'];
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Invalid label name', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_rejects_non_array_labels(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['labels'] = 'not_an_array';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Labels must be an array', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_requires_valid_value_type(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['value_type'] = 'invalid';
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Invalid value type', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_requires_option_name_for_option_type(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['value_type'] = 'option';
|
||||
$metric['value_config'] = [];
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Option name is required', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_accepts_option_type_with_option_name(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['value_type'] = 'option';
|
||||
$metric['value_config'] = ['option_name' => 'my_wp_option'];
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertEmpty($errors);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_rejects_too_many_label_values(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['labels'] = ['status'];
|
||||
$metric['label_values'] = array_fill(0, 51, ['active', 1.0]);
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('Maximum', implode(' ', $errors));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validate_checks_label_value_row_count(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['labels'] = ['status', 'type'];
|
||||
// Row has 2 items but needs 3 (2 labels + 1 value).
|
||||
$metric['label_values'] = [['active', 1.0]];
|
||||
|
||||
$errors = $this->builder->validate($metric);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertStringContainsString('values for all labels', implode(' ', $errors));
|
||||
}
|
||||
|
||||
// ── get_all() / get() ────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_all_returns_empty_array_by_default(): void
|
||||
{
|
||||
$this->assertSame([], $this->builder->get_all());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_all_returns_stored_metrics(): void
|
||||
{
|
||||
$metrics = ['id1' => ['name' => 'test_metric']];
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = $metrics;
|
||||
|
||||
$this->assertSame($metrics, $this->builder->get_all());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_all_returns_empty_when_option_is_not_array(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = 'not_an_array';
|
||||
$this->assertSame([], $this->builder->get_all());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_returns_metric_by_id(): void
|
||||
{
|
||||
$metric = ['id' => 'my-id', 'name' => 'test_metric', 'help' => 'Test'];
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = ['my-id' => $metric];
|
||||
|
||||
$this->assertSame($metric, $this->builder->get('my-id'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_returns_null_for_nonexistent_id(): void
|
||||
{
|
||||
$this->assertNull($this->builder->get('nonexistent'));
|
||||
}
|
||||
|
||||
// ── save() ───────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function save_creates_new_metric_and_returns_id(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$id = $this->builder->save($metric);
|
||||
|
||||
$this->assertNotEmpty($id);
|
||||
$this->assertGreaterThanOrEqual(1, GlobalFunctionState::getCallCount('update_option'));
|
||||
|
||||
$saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME];
|
||||
$this->assertArrayHasKey($id, $saved);
|
||||
$this->assertSame('custom_test_metric', $saved[$id]['name']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function save_throws_on_validation_failure(): void
|
||||
{
|
||||
$metric = [
|
||||
'name' => '',
|
||||
'help' => '',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
];
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->builder->save($metric);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function save_updates_existing_metric(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'existing-id' => [
|
||||
'id' => 'existing-id',
|
||||
'name' => 'custom_test_metric',
|
||||
'help' => 'Original help',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
'labels' => [],
|
||||
'created_at' => 1000000,
|
||||
],
|
||||
];
|
||||
|
||||
$metric = $this->validMetric();
|
||||
$metric['id'] = 'existing-id';
|
||||
$metric['help'] = 'Updated help';
|
||||
|
||||
$id = $this->builder->save($metric);
|
||||
|
||||
$this->assertSame('existing-id', $id);
|
||||
$saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME];
|
||||
$this->assertSame('Updated help', $saved['existing-id']['help']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function save_sanitizes_metric_data(): void
|
||||
{
|
||||
$metric = $this->validMetric();
|
||||
$metric['labels'] = ['valid_label'];
|
||||
$metric['label_values'] = [['active', 42.5]];
|
||||
|
||||
$id = $this->builder->save($metric);
|
||||
$saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME][$id];
|
||||
|
||||
$this->assertSame(['valid_label'], $saved['labels']);
|
||||
$this->assertSame([['active', 42.5]], $saved['label_values']);
|
||||
$this->assertIsBool($saved['enabled']);
|
||||
}
|
||||
|
||||
// ── delete() ─────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function delete_removes_metric(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'my-id' => ['id' => 'my-id', 'name' => 'custom_metric'],
|
||||
];
|
||||
|
||||
$this->assertTrue($this->builder->delete('my-id'));
|
||||
$this->assertArrayNotHasKey(
|
||||
'my-id',
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME]
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delete_returns_false_for_nonexistent(): void
|
||||
{
|
||||
$this->assertFalse($this->builder->delete('nonexistent'));
|
||||
}
|
||||
|
||||
// ── export() ─────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function export_returns_valid_json_with_metadata(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'id1' => ['name' => 'metric1', 'help' => 'Test 1'],
|
||||
];
|
||||
|
||||
$json = $this->builder->export();
|
||||
$data = json_decode($json, true);
|
||||
|
||||
$this->assertIsArray($data);
|
||||
$this->assertSame(CustomMetricBuilder::EXPORT_VERSION, $data['version']);
|
||||
$this->assertSame(WP_PROMETHEUS_VERSION, $data['plugin_version']);
|
||||
$this->assertArrayHasKey('exported_at', $data);
|
||||
$this->assertCount(1, $data['metrics']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function export_does_not_include_site_url(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [];
|
||||
|
||||
$json = $this->builder->export();
|
||||
$data = json_decode($json, true);
|
||||
|
||||
$this->assertArrayNotHasKey('site_url', $data);
|
||||
}
|
||||
|
||||
// ── import() ─────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function import_rejects_oversized_json(): void
|
||||
{
|
||||
$json = str_repeat('x', CustomMetricBuilder::MAX_IMPORT_SIZE + 1);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('exceeds maximum size');
|
||||
$this->builder->import($json);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_rejects_invalid_json(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid JSON');
|
||||
$this->builder->import('{invalid json');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_rejects_missing_metrics_key(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('No metrics found');
|
||||
$this->builder->import('{"version":"1.0.0"}');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_skip_mode_skips_duplicates(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'existing' => [
|
||||
'id' => 'existing',
|
||||
'name' => 'custom_existing_metric',
|
||||
'help' => 'Test',
|
||||
],
|
||||
];
|
||||
|
||||
$json = json_encode([
|
||||
'version' => '1.0.0',
|
||||
'metrics' => [
|
||||
[
|
||||
'name' => 'custom_existing_metric',
|
||||
'help' => 'Test',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->builder->import($json, 'skip');
|
||||
$this->assertSame(1, $result['skipped']);
|
||||
$this->assertSame(0, $result['imported']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_rename_mode_renames_duplicates(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'existing' => [
|
||||
'id' => 'existing',
|
||||
'name' => 'custom_existing_metric',
|
||||
'help' => 'Existing',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
'labels' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$json = json_encode([
|
||||
'version' => '1.0.0',
|
||||
'metrics' => [
|
||||
[
|
||||
'name' => 'custom_existing_metric',
|
||||
'help' => 'Imported',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
'labels' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->builder->import($json, 'rename');
|
||||
$this->assertSame(1, $result['imported']);
|
||||
|
||||
$all = $this->builder->get_all();
|
||||
$names = array_column($all, 'name');
|
||||
$this->assertContains('custom_existing_metric', $names);
|
||||
$this->assertContains('custom_existing_metric_imported_1', $names);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_invalid_mode_defaults_to_skip(): void
|
||||
{
|
||||
GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [
|
||||
'existing' => [
|
||||
'id' => 'existing',
|
||||
'name' => 'custom_existing_metric',
|
||||
'help' => 'Test',
|
||||
],
|
||||
];
|
||||
|
||||
$json = json_encode([
|
||||
'version' => '1.0.0',
|
||||
'metrics' => [
|
||||
[
|
||||
'name' => 'custom_existing_metric',
|
||||
'help' => 'Test',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->builder->import($json, 'invalid_mode');
|
||||
$this->assertSame(1, $result['skipped']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_counts_metrics_without_name_as_errors(): void
|
||||
{
|
||||
$json = json_encode([
|
||||
'version' => '1.0.0',
|
||||
'metrics' => [['help' => 'No name']],
|
||||
]);
|
||||
|
||||
$result = $this->builder->import($json);
|
||||
$this->assertSame(1, $result['errors']);
|
||||
$this->assertSame(0, $result['imported']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function import_successfully_imports_new_metric(): void
|
||||
{
|
||||
$json = json_encode([
|
||||
'version' => '1.0.0',
|
||||
'metrics' => [
|
||||
[
|
||||
'name' => 'custom_new_metric',
|
||||
'help' => 'A new metric',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
'labels' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->builder->import($json);
|
||||
$this->assertSame(1, $result['imported']);
|
||||
$this->assertSame(0, $result['skipped']);
|
||||
$this->assertSame(0, $result['errors']);
|
||||
|
||||
$all = $this->builder->get_all();
|
||||
$names = array_column($all, 'name');
|
||||
$this->assertContains('custom_new_metric', $names);
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function constants_are_defined(): void
|
||||
{
|
||||
$this->assertSame('wp_prometheus_custom_metrics', CustomMetricBuilder::OPTION_NAME);
|
||||
$this->assertSame(5, CustomMetricBuilder::MAX_LABELS);
|
||||
$this->assertSame(50, CustomMetricBuilder::MAX_LABEL_VALUES);
|
||||
$this->assertSame(1048576, CustomMetricBuilder::MAX_IMPORT_SIZE);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function validMetric(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'custom_test_metric',
|
||||
'help' => 'A test metric',
|
||||
'type' => 'gauge',
|
||||
'value_type' => 'static',
|
||||
'labels' => [],
|
||||
'label_values' => [],
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
234
tests/Unit/Metrics/RuntimeCollectorTest.php
Normal file
234
tests/Unit/Metrics/RuntimeCollectorTest.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
|
||||
|
||||
use Magdev\WpPrometheus\Metrics\RuntimeCollector;
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use Magdev\WpPrometheus\Tests\Unit\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
#[CoversClass(RuntimeCollector::class)]
|
||||
class RuntimeCollectorTest extends TestCase
|
||||
{
|
||||
private RuntimeCollector $collector;
|
||||
private array $originalServer = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->originalServer = $_SERVER;
|
||||
$this->collector = $this->createInstance();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SERVER = $this->originalServer;
|
||||
$this->resetSingleton();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Singleton ────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_instance_returns_singleton(): void
|
||||
{
|
||||
$instance1 = RuntimeCollector::get_instance();
|
||||
$instance2 = RuntimeCollector::get_instance();
|
||||
|
||||
$this->assertSame($instance1, $instance2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_instance_returns_runtime_collector(): void
|
||||
{
|
||||
$instance = RuntimeCollector::get_instance();
|
||||
$this->assertInstanceOf(RuntimeCollector::class, $instance);
|
||||
}
|
||||
|
||||
// ── get_stored_metrics() ─────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_stored_metrics_returns_default_when_empty(): void
|
||||
{
|
||||
$metrics = $this->collector->get_stored_metrics();
|
||||
$this->assertSame([], $metrics);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_stored_metrics_returns_stored_data(): void
|
||||
{
|
||||
$stored = [
|
||||
'counters' => ['key' => ['name' => 'test', 'value' => 5]],
|
||||
'histograms' => [],
|
||||
'last_reset' => 1000000,
|
||||
];
|
||||
GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = $stored;
|
||||
|
||||
$metrics = $this->collector->get_stored_metrics();
|
||||
$this->assertSame($stored, $metrics);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_stored_metrics_returns_default_for_non_array(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = 'invalid';
|
||||
|
||||
$metrics = $this->collector->get_stored_metrics();
|
||||
$this->assertArrayHasKey('counters', $metrics);
|
||||
$this->assertArrayHasKey('histograms', $metrics);
|
||||
$this->assertArrayHasKey('last_reset', $metrics);
|
||||
}
|
||||
|
||||
// ── reset_metrics() ──────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function reset_metrics_deletes_option(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = ['data'];
|
||||
|
||||
RuntimeCollector::reset_metrics();
|
||||
|
||||
$this->assertSame(1, GlobalFunctionState::getCallCount('delete_option'));
|
||||
$this->assertArrayNotHasKey(
|
||||
'wp_prometheus_runtime_metrics',
|
||||
GlobalFunctionState::$options
|
||||
);
|
||||
}
|
||||
|
||||
// ── get_duration_buckets() ───────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_duration_buckets_returns_expected_values(): void
|
||||
{
|
||||
$buckets = RuntimeCollector::get_duration_buckets();
|
||||
|
||||
$this->assertIsArray($buckets);
|
||||
$this->assertCount(11, $buckets);
|
||||
$this->assertSame(0.005, $buckets[0]);
|
||||
$this->assertEquals(10, end($buckets));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function duration_buckets_are_in_ascending_order(): void
|
||||
{
|
||||
$buckets = RuntimeCollector::get_duration_buckets();
|
||||
$sorted = $buckets;
|
||||
sort($sorted);
|
||||
|
||||
$this->assertSame($sorted, $buckets);
|
||||
}
|
||||
|
||||
// ── get_normalized_endpoint() (private, via reflection) ──────────
|
||||
|
||||
#[Test]
|
||||
public function normalized_endpoint_returns_admin_when_is_admin(): void
|
||||
{
|
||||
$_SERVER['REQUEST_URI'] = '/wp-admin/options-general.php';
|
||||
GlobalFunctionState::$options['__is_admin'] = true;
|
||||
|
||||
$result = $this->callPrivateMethod('get_normalized_endpoint');
|
||||
$this->assertSame('admin', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normalized_endpoint_returns_ajax_when_doing_ajax(): void
|
||||
{
|
||||
$_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php';
|
||||
GlobalFunctionState::$options['__wp_doing_ajax'] = true;
|
||||
|
||||
$result = $this->callPrivateMethod('get_normalized_endpoint');
|
||||
$this->assertSame('ajax', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normalized_endpoint_returns_cron_when_doing_cron(): void
|
||||
{
|
||||
// Use a generic URI (not /wp-cron.php) to ensure function is checked, not URL pattern.
|
||||
$_SERVER['REQUEST_URI'] = '/some-page';
|
||||
GlobalFunctionState::$options['__wp_doing_cron'] = true;
|
||||
|
||||
$result = $this->callPrivateMethod('get_normalized_endpoint');
|
||||
$this->assertSame('cron', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('urlEndpointProvider')]
|
||||
public function normalized_endpoint_from_url_pattern(string $uri, string $expected): void
|
||||
{
|
||||
$_SERVER['REQUEST_URI'] = $uri;
|
||||
|
||||
$result = $this->callPrivateMethod('get_normalized_endpoint');
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
public static function urlEndpointProvider(): array
|
||||
{
|
||||
return [
|
||||
'rest api' => ['/wp-json/wp/v2/posts', 'rest-api'],
|
||||
'login page' => ['/wp-login.php', 'login'],
|
||||
'login with query' => ['/wp-login.php?action=login', 'login'],
|
||||
'wp-cron' => ['/wp-cron.php', 'cron'],
|
||||
'feed root' => ['/feed/', 'feed'],
|
||||
'feed trailing' => ['/category/news/feed', 'feed'],
|
||||
'feed with slash' => ['/feed', 'feed'],
|
||||
'homepage' => ['/', 'frontend'],
|
||||
'page' => ['/about-us', 'frontend'],
|
||||
'post' => ['/2024/01/hello-world', 'frontend'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── is_metrics_request() (private, via reflection) ───────────────
|
||||
|
||||
#[Test]
|
||||
public function is_metrics_request_true_for_metrics_uri(): void
|
||||
{
|
||||
$_SERVER['REQUEST_URI'] = '/metrics';
|
||||
$this->assertTrue($this->callPrivateMethod('is_metrics_request'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function is_metrics_request_true_with_trailing_slash(): void
|
||||
{
|
||||
$_SERVER['REQUEST_URI'] = '/metrics/';
|
||||
$this->assertTrue($this->callPrivateMethod('is_metrics_request'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function is_metrics_request_false_for_other_uri(): void
|
||||
{
|
||||
$_SERVER['REQUEST_URI'] = '/some-page';
|
||||
$this->assertFalse($this->callPrivateMethod('is_metrics_request'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function is_metrics_request_false_when_no_uri(): void
|
||||
{
|
||||
unset($_SERVER['REQUEST_URI']);
|
||||
$this->assertFalse($this->callPrivateMethod('is_metrics_request'));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function createInstance(): RuntimeCollector
|
||||
{
|
||||
$reflection = new \ReflectionClass(RuntimeCollector::class);
|
||||
return $reflection->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
private function resetSingleton(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(RuntimeCollector::class);
|
||||
$property = $reflection->getProperty('instance');
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
|
||||
private function callPrivateMethod(string $method, array $args = []): mixed
|
||||
{
|
||||
$reflection = new \ReflectionMethod($this->collector, $method);
|
||||
return $reflection->invoke($this->collector, ...$args);
|
||||
}
|
||||
}
|
||||
411
tests/Unit/Metrics/StorageFactoryTest.php
Normal file
411
tests/Unit/Metrics/StorageFactoryTest.php
Normal file
@@ -0,0 +1,411 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit\Metrics;
|
||||
|
||||
use Magdev\WpPrometheus\Metrics\StorageFactory;
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use Magdev\WpPrometheus\Tests\Unit\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Prometheus\Storage\InMemory;
|
||||
|
||||
#[CoversClass(StorageFactory::class)]
|
||||
class StorageFactoryTest extends TestCase
|
||||
{
|
||||
/** @var list<string> Environment variables to clean up after each test. */
|
||||
private array $envVarsToClean = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
StorageFactory::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
StorageFactory::reset();
|
||||
foreach ($this->envVarsToClean as $var) {
|
||||
putenv($var);
|
||||
}
|
||||
$this->envVarsToClean = [];
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Adapter Constants ────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function adapter_constants_are_defined(): void
|
||||
{
|
||||
$this->assertSame('inmemory', StorageFactory::ADAPTER_INMEMORY);
|
||||
$this->assertSame('redis', StorageFactory::ADAPTER_REDIS);
|
||||
$this->assertSame('apcu', StorageFactory::ADAPTER_APCU);
|
||||
}
|
||||
|
||||
// ── get_available_adapters() ─────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_available_adapters_returns_all_three(): void
|
||||
{
|
||||
$adapters = StorageFactory::get_available_adapters();
|
||||
|
||||
$this->assertArrayHasKey(StorageFactory::ADAPTER_INMEMORY, $adapters);
|
||||
$this->assertArrayHasKey(StorageFactory::ADAPTER_REDIS, $adapters);
|
||||
$this->assertArrayHasKey(StorageFactory::ADAPTER_APCU, $adapters);
|
||||
$this->assertCount(3, $adapters);
|
||||
}
|
||||
|
||||
// ── is_adapter_available() ───────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function inmemory_adapter_is_always_available(): void
|
||||
{
|
||||
$this->assertTrue(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_INMEMORY));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unknown_adapter_is_not_available(): void
|
||||
{
|
||||
$this->assertFalse(StorageFactory::is_adapter_available('unknown'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function redis_availability_depends_on_extension(): void
|
||||
{
|
||||
$extensionLoaded = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'extension_loaded'
|
||||
);
|
||||
$extensionLoaded->expects($this->any())->willReturn(false);
|
||||
|
||||
$this->assertFalse(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_REDIS));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function apcu_availability_requires_extension_and_enabled(): void
|
||||
{
|
||||
$extensionLoaded = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'extension_loaded'
|
||||
);
|
||||
$extensionLoaded->expects($this->any())->willReturn(true);
|
||||
|
||||
$apcuEnabled = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'apcu_enabled'
|
||||
);
|
||||
$apcuEnabled->expects($this->any())->willReturn(false);
|
||||
|
||||
$this->assertFalse(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_APCU));
|
||||
}
|
||||
|
||||
// ── get_configured_adapter() ─────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function default_configured_adapter_is_inmemory(): void
|
||||
{
|
||||
$this->assertSame('inmemory', StorageFactory::get_configured_adapter());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function configured_adapter_reads_from_env_var(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis');
|
||||
|
||||
$this->assertSame('redis', StorageFactory::get_configured_adapter());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function configured_adapter_reads_from_option(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_storage_adapter'] = 'apcu';
|
||||
|
||||
$this->assertSame('apcu', StorageFactory::get_configured_adapter());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function env_var_takes_precedence_over_option(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis');
|
||||
GlobalFunctionState::$options['wp_prometheus_storage_adapter'] = 'apcu';
|
||||
|
||||
$this->assertSame('redis', StorageFactory::get_configured_adapter());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function configured_adapter_lowercases_env_value(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'REDIS');
|
||||
|
||||
$this->assertSame('redis', StorageFactory::get_configured_adapter());
|
||||
}
|
||||
|
||||
// ── get_redis_config() ───────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_redis_config_returns_defaults(): void
|
||||
{
|
||||
$config = StorageFactory::get_redis_config();
|
||||
|
||||
$this->assertSame('127.0.0.1', $config['host']);
|
||||
$this->assertSame(6379, $config['port']);
|
||||
$this->assertSame('', $config['password']);
|
||||
$this->assertSame(0, $config['database']);
|
||||
$this->assertSame('WORDPRESS_PROMETHEUS_', $config['prefix']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_redis_config_reads_from_env_vars(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_REDIS_HOST', '10.0.0.1');
|
||||
$this->setEnv('WP_PROMETHEUS_REDIS_PORT', '6380');
|
||||
$this->setEnv('WP_PROMETHEUS_REDIS_PASSWORD', 's3cret');
|
||||
$this->setEnv('WP_PROMETHEUS_REDIS_DATABASE', '2');
|
||||
$this->setEnv('WP_PROMETHEUS_REDIS_PREFIX', 'MY_PREFIX_');
|
||||
|
||||
$config = StorageFactory::get_redis_config();
|
||||
|
||||
$this->assertSame('10.0.0.1', $config['host']);
|
||||
$this->assertSame(6380, $config['port']);
|
||||
$this->assertSame('s3cret', $config['password']);
|
||||
$this->assertSame(2, $config['database']);
|
||||
$this->assertSame('MY_PREFIX_', $config['prefix']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_redis_config_reads_from_option(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_redis_config'] = [
|
||||
'host' => '192.168.1.1',
|
||||
'port' => 6381,
|
||||
'password' => 'optpass',
|
||||
'database' => 3,
|
||||
'prefix' => 'OPT_',
|
||||
];
|
||||
|
||||
$config = StorageFactory::get_redis_config();
|
||||
|
||||
$this->assertSame('192.168.1.1', $config['host']);
|
||||
$this->assertSame(6381, $config['port']);
|
||||
$this->assertSame('optpass', $config['password']);
|
||||
$this->assertSame(3, $config['database']);
|
||||
$this->assertSame('OPT_', $config['prefix']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_redis_config_env_takes_precedence(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_REDIS_HOST', 'env-host');
|
||||
GlobalFunctionState::$options['wp_prometheus_redis_config'] = [
|
||||
'host' => 'option-host',
|
||||
];
|
||||
|
||||
$config = StorageFactory::get_redis_config();
|
||||
$this->assertSame('env-host', $config['host']);
|
||||
}
|
||||
|
||||
// ── get_apcu_prefix() ────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_apcu_prefix_returns_default(): void
|
||||
{
|
||||
$this->assertSame('wp_prom', StorageFactory::get_apcu_prefix());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_apcu_prefix_reads_from_env(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_APCU_PREFIX', 'custom_prefix');
|
||||
|
||||
$this->assertSame('custom_prefix', StorageFactory::get_apcu_prefix());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_apcu_prefix_reads_from_option(): void
|
||||
{
|
||||
GlobalFunctionState::$options['wp_prometheus_apcu_prefix'] = 'opt_prefix';
|
||||
|
||||
$this->assertSame('opt_prefix', StorageFactory::get_apcu_prefix());
|
||||
}
|
||||
|
||||
// ── save_config() ────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function save_config_stores_adapter(): void
|
||||
{
|
||||
StorageFactory::save_config(['adapter' => 'redis']);
|
||||
|
||||
$this->assertSame(
|
||||
'redis',
|
||||
GlobalFunctionState::$options['wp_prometheus_storage_adapter']
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function save_config_stores_redis_config(): void
|
||||
{
|
||||
StorageFactory::save_config([
|
||||
'redis' => [
|
||||
'host' => '10.0.0.5',
|
||||
'port' => 6390,
|
||||
'password' => 'pass123',
|
||||
'database' => 1,
|
||||
'prefix' => 'TEST_',
|
||||
],
|
||||
]);
|
||||
|
||||
$saved = GlobalFunctionState::$options['wp_prometheus_redis_config'];
|
||||
$this->assertSame('10.0.0.5', $saved['host']);
|
||||
$this->assertSame(6390, $saved['port']);
|
||||
$this->assertSame('pass123', $saved['password']);
|
||||
$this->assertSame(1, $saved['database']);
|
||||
// sanitize_key lowercases the prefix
|
||||
$this->assertSame('test_', $saved['prefix']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function save_config_stores_apcu_prefix(): void
|
||||
{
|
||||
StorageFactory::save_config(['apcu_prefix' => 'my_apcu']);
|
||||
|
||||
$this->assertSame(
|
||||
'my_apcu',
|
||||
GlobalFunctionState::$options['wp_prometheus_apcu_prefix']
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function save_config_resets_singleton(): void
|
||||
{
|
||||
// Get an adapter (creates singleton).
|
||||
$adapter1 = StorageFactory::get_adapter();
|
||||
|
||||
// Save new config (should reset singleton).
|
||||
StorageFactory::save_config(['adapter' => 'inmemory']);
|
||||
|
||||
// Get adapter again — should be a new instance.
|
||||
$adapter2 = StorageFactory::get_adapter();
|
||||
$this->assertNotSame($adapter1, $adapter2);
|
||||
}
|
||||
|
||||
// ── test_connection() ────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function test_connection_inmemory_always_succeeds(): void
|
||||
{
|
||||
$result = StorageFactory::test_connection(StorageFactory::ADAPTER_INMEMORY);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertNotEmpty($result['message']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function test_connection_unknown_adapter_fails(): void
|
||||
{
|
||||
$result = StorageFactory::test_connection('unknown');
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertStringContainsString('Unknown', $result['message']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function test_connection_redis_fails_without_extension(): void
|
||||
{
|
||||
$extensionLoaded = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'extension_loaded'
|
||||
);
|
||||
$extensionLoaded->expects($this->any())->willReturn(false);
|
||||
|
||||
$result = StorageFactory::test_connection(StorageFactory::ADAPTER_REDIS);
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertStringContainsString('not installed', $result['message']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function test_connection_apcu_fails_without_extension(): void
|
||||
{
|
||||
$extensionLoaded = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'extension_loaded'
|
||||
);
|
||||
$extensionLoaded->expects($this->any())->willReturn(false);
|
||||
|
||||
$result = StorageFactory::test_connection(StorageFactory::ADAPTER_APCU);
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertStringContainsString('not installed', $result['message']);
|
||||
}
|
||||
|
||||
// ── get_adapter() / reset() / singleton ──────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function get_adapter_returns_inmemory_by_default(): void
|
||||
{
|
||||
$adapter = StorageFactory::get_adapter();
|
||||
$this->assertInstanceOf(InMemory::class, $adapter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_adapter_returns_singleton(): void
|
||||
{
|
||||
$adapter1 = StorageFactory::get_adapter();
|
||||
$adapter2 = StorageFactory::get_adapter();
|
||||
|
||||
$this->assertSame($adapter1, $adapter2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reset_clears_singleton_and_error(): void
|
||||
{
|
||||
StorageFactory::get_adapter();
|
||||
StorageFactory::reset();
|
||||
|
||||
// After reset, get_last_error should be empty.
|
||||
$this->assertEmpty(StorageFactory::get_last_error());
|
||||
|
||||
// Getting adapter again creates a new instance.
|
||||
$adapter = StorageFactory::get_adapter();
|
||||
$this->assertInstanceOf(InMemory::class, $adapter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_adapter_falls_back_to_inmemory_when_redis_unavailable(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis');
|
||||
|
||||
$extensionLoaded = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'extension_loaded'
|
||||
);
|
||||
$extensionLoaded->expects($this->any())->willReturn(false);
|
||||
|
||||
$adapter = StorageFactory::get_adapter();
|
||||
$this->assertInstanceOf(InMemory::class, $adapter);
|
||||
$this->assertNotEmpty(StorageFactory::get_last_error());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_adapter_falls_back_to_inmemory_when_apcu_unavailable(): void
|
||||
{
|
||||
$this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'apcu');
|
||||
|
||||
$extensionLoaded = $this->getFunctionMock(
|
||||
'Magdev\\WpPrometheus\\Metrics',
|
||||
'extension_loaded'
|
||||
);
|
||||
$extensionLoaded->expects($this->any())->willReturn(false);
|
||||
|
||||
$adapter = StorageFactory::get_adapter();
|
||||
$this->assertInstanceOf(InMemory::class, $adapter);
|
||||
$this->assertNotEmpty(StorageFactory::get_last_error());
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function setEnv(string $name, string $value): void
|
||||
{
|
||||
putenv("$name=$value");
|
||||
$this->envVarsToClean[] = $name;
|
||||
}
|
||||
}
|
||||
32
tests/Unit/TestCase.php
Normal file
32
tests/Unit/TestCase.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WpPrometheus\Tests\Unit;
|
||||
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
|
||||
|
||||
/**
|
||||
* Base test case for WP Prometheus unit tests.
|
||||
*
|
||||
* Provides the PHPMock trait for mocking WordPress functions
|
||||
* called from namespaced code, and resets global state between tests.
|
||||
*/
|
||||
abstract class TestCase extends PHPUnitTestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
GlobalFunctionState::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
GlobalFunctionState::reset();
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
450
tests/bootstrap.php
Normal file
450
tests/bootstrap.php
Normal file
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
/**
|
||||
* PHPUnit bootstrap file for WP Prometheus tests.
|
||||
*
|
||||
* Defines WordPress constants and global function stubs required
|
||||
* for loading plugin source files without a WordPress environment.
|
||||
*
|
||||
* @package WP_Prometheus\Tests
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Magdev\WpPrometheus\Tests\Helpers\GlobalFunctionState;
|
||||
|
||||
// 1. Load Composer autoloader first (for GlobalFunctionState class).
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
// 2. Define WordPress constants required by source files.
|
||||
define('ABSPATH', '/tmp/wordpress/');
|
||||
define('WP_CONTENT_DIR', '/tmp/wordpress/wp-content');
|
||||
define('WP_PROMETHEUS_VERSION', '0.5.0');
|
||||
define('WP_PROMETHEUS_FILE', dirname(__DIR__) . '/wp-prometheus.php');
|
||||
define('WP_PROMETHEUS_PATH', dirname(__DIR__) . '/');
|
||||
define('WP_PROMETHEUS_URL', 'https://example.com/wp-content/plugins/wp-prometheus/');
|
||||
define('WP_PROMETHEUS_BASENAME', 'wp-prometheus/wp-prometheus.php');
|
||||
|
||||
// 3. Define global WordPress function stubs.
|
||||
// These exist so plugin files can be require'd without fatal errors.
|
||||
// Per-test behavior in namespaced code is controlled via php-mock.
|
||||
// Global-scope tests use GlobalFunctionState for controllable behavior.
|
||||
|
||||
// -- Translation functions --
|
||||
if (!function_exists('__')) {
|
||||
function __(string $text, string $domain = 'default'): string
|
||||
{
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('_e')) {
|
||||
function _e(string $text, string $domain = 'default'): void
|
||||
{
|
||||
echo $text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esc_html__')) {
|
||||
function esc_html__(string $text, string $domain = 'default'): string
|
||||
{
|
||||
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esc_html_e')) {
|
||||
function esc_html_e(string $text, string $domain = 'default'): void
|
||||
{
|
||||
echo htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
// -- Escaping functions --
|
||||
if (!function_exists('esc_html')) {
|
||||
function esc_html(string $text): string
|
||||
{
|
||||
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esc_attr')) {
|
||||
function esc_attr(string $text): string
|
||||
{
|
||||
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esc_url')) {
|
||||
function esc_url(string $url): string
|
||||
{
|
||||
return filter_var($url, FILTER_SANITIZE_URL) ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esc_url_raw')) {
|
||||
function esc_url_raw(string $url): string
|
||||
{
|
||||
return filter_var($url, FILTER_SANITIZE_URL) ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
// -- Sanitization functions --
|
||||
if (!function_exists('sanitize_text_field')) {
|
||||
function sanitize_text_field(string $str): string
|
||||
{
|
||||
return trim(strip_tags($str));
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('sanitize_key')) {
|
||||
function sanitize_key(string $key): string
|
||||
{
|
||||
return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key));
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('sanitize_file_name')) {
|
||||
function sanitize_file_name(string $name): string
|
||||
{
|
||||
return preg_replace('/[^a-zA-Z0-9_.\-]/', '', $name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('sanitize_html_class')) {
|
||||
function sanitize_html_class(string $class): string
|
||||
{
|
||||
return preg_replace('/[^a-zA-Z0-9_\-]/', '', $class);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('absint')) {
|
||||
function absint($value): int
|
||||
{
|
||||
return abs((int) $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_unslash')) {
|
||||
function wp_unslash($value)
|
||||
{
|
||||
return is_string($value) ? stripslashes($value) : $value;
|
||||
}
|
||||
}
|
||||
|
||||
// -- WordPress utility functions --
|
||||
if (!function_exists('wp_parse_url')) {
|
||||
function wp_parse_url(string $url, int $component = -1)
|
||||
{
|
||||
return parse_url($url, $component);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_json_encode')) {
|
||||
function wp_json_encode($data, int $options = 0, int $depth = 512)
|
||||
{
|
||||
return json_encode($data, $options, $depth);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_generate_uuid4')) {
|
||||
function wp_generate_uuid4(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_generate_password')) {
|
||||
function wp_generate_password(int $length = 12, bool $special = true): string
|
||||
{
|
||||
return substr(bin2hex(random_bytes($length)), 0, $length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('size_format')) {
|
||||
function size_format(int $bytes, int $decimals = 0): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
|
||||
return number_format($bytes / pow(1024, $power), $decimals) . ' ' . $units[$power];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('path_is_absolute')) {
|
||||
function path_is_absolute(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, '/') || (bool) preg_match('#^[a-zA-Z]:\\\\#', $path);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Hook functions (no-ops) --
|
||||
if (!function_exists('add_action')) {
|
||||
function add_action(string $hook, $callback, int $priority = 10, int $accepted_args = 1): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('add_filter')) {
|
||||
function add_filter(string $hook, $callback, int $priority = 10, int $accepted_args = 1): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('remove_all_filters')) {
|
||||
function remove_all_filters(string $hook, $priority = false): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('do_action')) {
|
||||
function do_action(string $hook, ...$args): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('apply_filters')) {
|
||||
function apply_filters(string $hook, $value, ...$args)
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Option functions (controllable via GlobalFunctionState) --
|
||||
if (!function_exists('get_option')) {
|
||||
function get_option(string $option, $default = false)
|
||||
{
|
||||
if (array_key_exists($option, GlobalFunctionState::$options)) {
|
||||
return GlobalFunctionState::$options[$option];
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('update_option')) {
|
||||
function update_option(string $option, $value, $autoload = null): bool
|
||||
{
|
||||
GlobalFunctionState::recordCall('update_option', $option, $value);
|
||||
GlobalFunctionState::$options[$option] = $value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('delete_option')) {
|
||||
function delete_option(string $option): bool
|
||||
{
|
||||
GlobalFunctionState::recordCall('delete_option', $option);
|
||||
unset(GlobalFunctionState::$options[$option]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('delete_transient')) {
|
||||
function delete_transient(string $transient): bool
|
||||
{
|
||||
GlobalFunctionState::recordCall('delete_transient', $transient);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('flush_rewrite_rules')) {
|
||||
function flush_rewrite_rules(bool $hard = true): void
|
||||
{
|
||||
GlobalFunctionState::recordCall('flush_rewrite_rules');
|
||||
}
|
||||
}
|
||||
|
||||
// -- URL functions --
|
||||
if (!function_exists('home_url')) {
|
||||
function home_url(string $path = ''): string
|
||||
{
|
||||
return 'https://example.com' . $path;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('admin_url')) {
|
||||
function admin_url(string $path = ''): string
|
||||
{
|
||||
return 'https://example.com/wp-admin/' . $path;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Conditional functions (controllable via GlobalFunctionState) --
|
||||
if (!function_exists('is_admin')) {
|
||||
function is_admin(): bool
|
||||
{
|
||||
return GlobalFunctionState::$options['__is_admin'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_doing_ajax')) {
|
||||
function wp_doing_ajax(): bool
|
||||
{
|
||||
return GlobalFunctionState::$options['__wp_doing_ajax'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_doing_cron')) {
|
||||
function wp_doing_cron(): bool
|
||||
{
|
||||
return GlobalFunctionState::$options['__wp_doing_cron'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('is_multisite')) {
|
||||
function is_multisite(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Plugin functions --
|
||||
if (!function_exists('load_plugin_textdomain')) {
|
||||
function load_plugin_textdomain(string $domain, $deprecated = false, string $path = ''): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('register_activation_hook')) {
|
||||
function register_activation_hook(string $file, $callback): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('register_deactivation_hook')) {
|
||||
function register_deactivation_hook(string $file, $callback): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// -- Rewrite functions --
|
||||
if (!function_exists('add_rewrite_rule')) {
|
||||
function add_rewrite_rule(string $regex, string $redirect, string $after = 'bottom'): void
|
||||
{
|
||||
GlobalFunctionState::recordCall('add_rewrite_rule', $regex, $redirect, $after);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('add_rewrite_tag')) {
|
||||
function add_rewrite_tag(string $tag, string $regex, string $query = ''): void
|
||||
{
|
||||
GlobalFunctionState::recordCall('add_rewrite_tag', $tag, $regex, $query);
|
||||
}
|
||||
}
|
||||
|
||||
// -- HTTP functions --
|
||||
if (!function_exists('status_header')) {
|
||||
function status_header(int $code, string $description = ''): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('hash_equals')) {
|
||||
// hash_equals is a PHP built-in, but define stub just in case.
|
||||
}
|
||||
|
||||
if (!function_exists('wp_rand')) {
|
||||
function wp_rand(int $min = 0, int $max = 0): int
|
||||
{
|
||||
return random_int($min, max($min, $max ?: PHP_INT_MAX >> 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_bloginfo')) {
|
||||
function get_bloginfo(string $show = '', bool $filter = true): string
|
||||
{
|
||||
return match ($show) {
|
||||
'version' => '6.7',
|
||||
'language' => 'en-US',
|
||||
'name' => 'Test Site',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('current_user_can')) {
|
||||
function current_user_can(string $capability, ...$args): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('deactivate_plugins')) {
|
||||
function deactivate_plugins($plugins, bool $silent = false, $network_wide = null): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_die')) {
|
||||
function wp_die($message = '', $title = '', $args = []): void
|
||||
{
|
||||
throw new \RuntimeException((string) $message);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Plugin global authentication functions (from wp-prometheus.php) --
|
||||
// Cannot include wp-prometheus.php directly due to constant definitions
|
||||
// and side effects. These mirror the production code for testing.
|
||||
|
||||
if (!function_exists('wp_prometheus_get_authorization_header')) {
|
||||
function wp_prometheus_get_authorization_header(): string
|
||||
{
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
return sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION']));
|
||||
}
|
||||
|
||||
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
return sanitize_text_field(wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION']));
|
||||
}
|
||||
|
||||
if (function_exists('apache_request_headers')) {
|
||||
$headers = apache_request_headers();
|
||||
if (isset($headers['Authorization'])) {
|
||||
return sanitize_text_field($headers['Authorization']);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// -- WordPress core class stubs --
|
||||
if (!class_exists('WP')) {
|
||||
class WP
|
||||
{
|
||||
public array $query_vars = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('wp_prometheus_authenticate_request')) {
|
||||
function wp_prometheus_authenticate_request(): bool
|
||||
{
|
||||
$auth_token = get_option('wp_prometheus_auth_token', '');
|
||||
|
||||
// If no token is set, deny access.
|
||||
if (empty($auth_token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Bearer token in Authorization header.
|
||||
$auth_header = wp_prometheus_get_authorization_header();
|
||||
if (!empty($auth_header) && preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) {
|
||||
return hash_equals($auth_token, $matches[1]);
|
||||
}
|
||||
|
||||
// Check for token in query parameter.
|
||||
if (isset($_GET['token']) && hash_equals($auth_token, sanitize_text_field(wp_unslash($_GET['token'])))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: WP Prometheus
|
||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus
|
||||
* Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics.
|
||||
* Version: 0.4.8
|
||||
* Version: 0.5.0
|
||||
* Requires at least: 6.4
|
||||
* Requires PHP: 8.3
|
||||
* Author: Marco Graetsch
|
||||
@@ -199,7 +199,7 @@ wp_prometheus_early_metrics_check();
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
define( 'WP_PROMETHEUS_VERSION', '0.4.8' );
|
||||
define( 'WP_PROMETHEUS_VERSION', '0.5.0' );
|
||||
|
||||
/**
|
||||
* Plugin file path.
|
||||
|
||||
Reference in New Issue
Block a user