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, ]; } }