diff --git a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md index 20128e19d..8d002ccb4 100644 --- a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md +++ b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md @@ -104,6 +104,20 @@ add_action( 'plugins_loaded', function() { With this in place, all logs from WPGraphQL Logging will be routed through your `MyFileLogService` and saved to a file instead of the database. +## Supporting search in the admin + +The search box on GraphQL Logs -> All Logs passes the search term to `find_entities_by_where()` and `count_entities_by_where()` as a where condition with the pseudo-column `LogServiceInterface::SEARCH_COLUMN`: + +```php +[ + 'column' => LogServiceInterface::SEARCH_COLUMN, // 'search' + 'operator' => 'LIKE', + 'value' => 'GetPosts', // The raw search term, not a LIKE pattern. +] +``` + +Your service decides which fields to match the term against. If it doesn't support search, it can ignore this condition. + ## Contributing We welcome and appreciate contributions from the community. If you'd like to help improve this documentation, please check out our [Contributing Guide](https://github.com/wpengine/hwptoolkit/blob/main/CONTRIBUTING.md) for more details on how to get started. diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 0231ad998..62e9c9a34 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -38,7 +38,7 @@ The plugin provides comprehensive data management capabilities, allowing you to ### Admin UI -The plugin delivers a admin UI with some filtering of logs around dates or level and the ability to download logs in CSV format. +The plugin delivers a admin UI with a search box, some filtering of logs around dates or level and the ability to download logs in CSV format. *** @@ -142,6 +142,14 @@ ID,Date,Level,"Level Name",Message,Channel,Query,Context,Extra 5293,"2025-10-06 15:41:34",200,INFO,"WPGraphQL Response",wpgraphql_logging,"{ posts(first: 10) ...""memory_peak_usage"":""18 MB""}" ``` +### Searching Logs + +Use the search box to find logs by their message (for example `WPGraphQL Response with Errors`) or by the text of the GraphQL query, such as an operation name like `GetPosts`. The search is case-insensitive, and it works together with the filters, sorting and pagination. + +To stay fast on large log tables, the search only looks at the message and the `query` in the log context, not the variables, request or response. A `LIKE '%term%'` search can't use an index, so combine it with a date or level filter when you have many logs. + +Developers can change or remove the search condition with the `wpgraphql_logging_logs_table_where_clauses` filter. + ### Filtering Logs You can also filter logs with the following filters: diff --git a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php index cd7e9028d..9b6afbb13 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php +++ b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php @@ -36,6 +36,13 @@ class ListTable extends WP_List_Table { */ public const DEFAULT_PER_PAGE = 20; + /** + * Maximum length of the search term. + * + * @var int + */ + public const MAX_SEARCH_LENGTH = 200; + /** * Constructor. * @@ -164,12 +171,11 @@ public function process_bulk_action(): void { } $preserved_filters = []; - $filter_keys = [ 'level_filter', 'start_date', 'end_date' ]; - foreach ( $filter_keys as $key ) { + foreach ( ViewLogsPage::FILTER_KEYS as $key ) { $value = isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : null; if ( null !== $value && '' !== $value ) { - $preserved_filters[ $key ] = $value; + $preserved_filters[ $key ] = rawurlencode( $value ); } } @@ -467,6 +473,17 @@ protected function process_where(array $request): array { ]; } + if ( isset( $request['s'] ) && is_string( $request['s'] ) ) { + $search = mb_substr( sanitize_text_field( wp_unslash( $request['s'] ) ), 0, self::MAX_SEARCH_LENGTH ); + if ( '' !== $search ) { + $where_clauses[] = [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => $search, + ]; + } + } + // Allow developers to modify the where clauses. return apply_filters( 'wpgraphql_logging_logs_table_where_clauses', $where_clauses, $request ); } diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php index 25fb6a7fd..b466176ac 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php +++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php @@ -13,9 +13,10 @@ exit; } -$wpgraphql_logging_current_level = isset( $_POST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_POST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -$wpgraphql_logging_current_start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -$wpgraphql_logging_current_end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing +// Filters are submitted by POST and redirected to GET, so read them from the request. +$wpgraphql_logging_current_level = isset( $_REQUEST['level_filter'] ) && is_string( $_REQUEST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended +$wpgraphql_logging_current_start_date = isset( $_REQUEST['start_date'] ) && is_string( $_REQUEST['start_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended +$wpgraphql_logging_current_end_date = isset( $_REQUEST['end_date'] ) && is_string( $_REQUEST['end_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Currently only use info and error. $wpgraphql_logging_log_levels = [ 'info', 'error' ]; @@ -23,6 +24,8 @@ ?>
+ + prepare_items(); ?> + search_box( __( 'Search Logs', 'wpgraphql-logging' ), 'wpgraphql-logging' ); ?> display(); ?>
diff --git a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php index 78c48c4b0..93f9f26cc 100644 --- a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php +++ b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php @@ -38,6 +38,13 @@ class ViewLogsPage { */ public const ADMIN_PAGE_DOWNLOAD_NONCE = 'wp_graphql_logging_download'; + /** + * The request keys for the search and filters of the logs list. + * + * @var array + */ + public const FILTER_KEYS = [ 'start_date', 'end_date', 'level_filter', 's' ]; + /** * The hook suffix for the admin page. * @@ -229,6 +236,11 @@ public function process_filters_redirect(): void { return; } + // The filters share a form with the bulk actions, which ListTable handles itself. + if ( $this->is_bulk_action_request() ) { + return; + } + $redirect_url = $this->get_redirect_url(); wp_safe_redirect( $redirect_url ); @@ -243,13 +255,7 @@ public function process_filters_redirect(): void { public function get_redirect_url(): string { $redirect_url = menu_page_url( self::ADMIN_PAGE_SLUG, false ); - $possible_filters = [ - 'start_date', - 'end_date', - 'level_filter', - 'orderby', - 'order', - ]; + $possible_filters = [ ...self::FILTER_KEYS, 'orderby', 'order' ]; $filters = []; foreach ( $possible_filters as $key ) { $value = $this->get_post_value( $key ); @@ -258,13 +264,54 @@ public function get_redirect_url(): string { } } - $redirect_url = add_query_arg( array_filter( $filters, static function ( $value ) { + // Keep the typed page number only when the search and filters did not change. + $paged = $this->get_post_value( 'paged' ); + if ( null !== $paged && absint( $paged ) > 1 && ! $this->filters_changed( $filters ) ) { + $filters['paged'] = (string) absint( $paged ); + } + + // add_query_arg() does not encode values, and a search term can contain "&", "#" or "{". + $redirect_url = add_query_arg( array_map( 'rawurlencode', array_filter( $filters, static function ( $value ) { return '' !== $value; - } ), $redirect_url ); + } ) ), $redirect_url ); return (string) apply_filters( 'wpgraphql_logging_filter_redirect_url', $redirect_url, $filters ); } + /** + * Whether the submitted search or filters differ from the ones in the current URL. + * + * @param array $filters The submitted filters. + * + * @phpcs:disable WordPress.Security.NonceVerification.Recommended + * + * @return bool True if any search or filter value changed. + */ + protected function filters_changed(array $filters): bool { + foreach ( self::FILTER_KEYS as $key ) { + $current = isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; + if ( ( $filters[ $key ] ?? '' ) !== $current ) { + return true; + } + } + return false; + } + + /** + * Whether the submitted form is requesting a bulk action. + * + * @return bool True if a bulk action other than the "-1" placeholder was selected. + */ + protected function is_bulk_action_request(): bool { + foreach ( [ 'action', 'action2' ] as $key ) { + $value = $this->get_post_value( $key ); + if ( null !== $value && '-1' !== $value ) { + return true; + } + } + return false; + } + /** * Retrieves and sanitizes a value from the $_POST superglobal. * diff --git a/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php b/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php index cd636a842..1e881bec7 100644 --- a/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php +++ b/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php @@ -14,6 +14,17 @@ * @since 0.0.1 */ interface LogServiceInterface { + /** + * Pseudo-column used in where conditions for a free-text search. + * + * A condition of `[ 'column' => self::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => $term ]` + * asks the service to match `$term` against whichever fields it considers searchable. + * Services that do not support search may ignore it. + * + * @var string + */ + public const SEARCH_COLUMN = 'search'; + /** * Activates the log service. */ diff --git a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php index b5feca4a8..12d833e90 100644 --- a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php +++ b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php @@ -250,6 +250,10 @@ protected function prepare_sql(string $sql, array $where_conditions): string { if ( '' === $column ) { continue; } + if ( self::SEARCH_COLUMN === $column ) { + $where_clauses[] = $this->prepare_search_clause( $condition ); + continue; + } if ( ! in_array( $column, $allowed_columns, true ) ) { continue; } @@ -264,6 +268,7 @@ protected function prepare_sql(string $sql, array $where_conditions): string { $this->where_values[] = $value; } + $where_clauses = array_filter( $where_clauses, static fn( string $clause ): bool => '' !== $clause ); if ( ! empty( $where_clauses ) ) { $sql .= ' WHERE ' . implode( ' AND ', $where_clauses ); } @@ -271,6 +276,42 @@ protected function prepare_sql(string $sql, array $where_conditions): string { return $sql; } + /** + * Builds the where clause for a free-text search condition. + * + * Only the message column and the top-level `query` key of the context JSON are searched. + * A leading-wildcard LIKE cannot use an index, so the set is kept small on purpose: the + * rest of the context (variables, request, response) can be large and would match noise. + * Filtering by date or level narrows the rows first via the existing indexes. + * + * @param array{column: string, operator: string, value: string} $condition The search condition. + * + * @return string The where clause, or an empty string if the condition is invalid. + */ + protected function prepare_search_clause(array $condition): string { + global $wpdb; + + if ( 'LIKE' !== $condition['operator'] || ! is_string( $condition['value'] ) ) { + return ''; + } + + $term = trim( $condition['value'] ); + if ( '' === $term ) { + return ''; + } + + $like = '%' . $wpdb->esc_like( $term ) . '%'; + + // JSON_UNQUOTE() returns a binary collation on MySQL, so compare lowercase to stay case-insensitive. + $this->where_values[] = 'message'; + $this->where_values[] = $like; + $this->where_values[] = 'context'; + $this->where_values[] = '$.query'; + $this->where_values[] = $like; + + return '(%i LIKE %s OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(%i, %s))) LIKE LOWER(%s))'; + } + /** * The safe operators. * diff --git a/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js b/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js new file mode 100644 index 000000000..35b6a4a5b --- /dev/null +++ b/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js @@ -0,0 +1,80 @@ +import { expect, test } from "@wordpress/e2e-test-utils-playwright"; +import { + goToLoggingSettingsPage, + goToLogsListPage, + configureLogging, + executeGraphQLQuery, + resetPluginSettings, +} from "../utils"; +import { GET_POSTS_QUERY } from "../constants"; + +const GET_SETTINGS_QUERY = ` + query GetSiteSettings { + generalSettings { + title + } + } +`; + +test.describe("Search Logs", () => { + test.beforeEach(async ({ admin, page, request }) => { + await resetPluginSettings(admin); + + await goToLoggingSettingsPage(admin); + await configureLogging(page, { + enabled: true, + dataSampling: "100", + eventLogSelection: ["graphql_request_results"], + }); + + expect((await executeGraphQLQuery(request, GET_POSTS_QUERY)).ok()).toBeTruthy(); + expect((await executeGraphQLQuery(request, GET_SETTINGS_QUERY)).ok()).toBeTruthy(); + }); + + test("finds logs by query text", async ({ page, admin }) => { + await goToLogsListPage(admin); + + const rows = page.locator("#the-list tr"); + await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(1); + await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1); + + const searchInput = page.locator("#wpgraphql-logging-search-input"); + await searchInput.fill("getsitesettings"); + await searchInput.press("Enter"); + + // The search is redirected to GET so it stays in the pagination and sort links. + await expect(page).toHaveURL(/[?&]s=getsitesettings(&|$)/); + await expect(searchInput).toHaveValue("getsitesettings"); + await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1); + await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(0); + }); + + test("combines search with the level filter", async ({ page, admin }) => { + await goToLogsListPage(admin); + + await page.locator("#wpgraphql-logging-search-input").fill("GetPosts"); + await page.locator('select[name="level_filter"]').selectOption("error"); + await page.getByRole("button", { name: "Filter", exact: true }).click(); + + await expect(page).toHaveURL(/level_filter=error/); + await expect(page).toHaveURL(/[?&]s=GetPosts(&|$)/); + await expect(page.locator('select[name="level_filter"]')).toHaveValue("error"); + await expect( + page.locator('td.colspanchange:has-text("No items found.")') + ).toBeVisible(); + }); + + test("shows no results for an unmatched search", async ({ page, admin }) => { + await goToLogsListPage(admin); + + const searchInput = page.locator("#wpgraphql-logging-search-input"); + await searchInput.fill("NoSuchOperation"); + await searchInput.press("Enter"); + + await expect( + page.locator('td.colspanchange:has-text("No items found.")') + ).toBeVisible(); + // The search box stays visible so the search can be changed. + await expect(searchInput).toHaveValue("NoSuchOperation"); + }); +}); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php index 9e9403c04..d1627db4a 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php @@ -6,6 +6,7 @@ use WPGraphQL\Logging\Admin\View\List\ListTable; +use WPGraphQL\Logging\Logger\Api\LogServiceInterface; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService; use Codeception\TestCase\WPTestCase; @@ -303,6 +304,63 @@ public function test_process_where_handles_date_filters(): void { ], $result); } + public function test_process_where_handles_search(): void { + $reflection = new \ReflectionClass($this->list_table); + $method = $reflection->getMethod('process_where'); + $method->setAccessible(true); + + $request = [ + 'level_filter' => 'error', + 's' => ' GetPosts ', + ]; + $result = $method->invoke($this->list_table, $request); + + $this->assertSame([ + [ + 'column' => 'level_name', + 'operator' => '=', + 'value' => 'error', + ], + [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => 'GetPosts', + ], + ], $result); + } + + public function test_process_where_ignores_empty_and_caps_long_search(): void { + $reflection = new \ReflectionClass($this->list_table); + $method = $reflection->getMethod('process_where'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke($this->list_table, ['s' => ' '])); + $this->assertSame([], $method->invoke($this->list_table, ['s' => ['array']])); + + $result = $method->invoke($this->list_table, ['s' => str_repeat('a', ListTable::MAX_SEARCH_LENGTH + 50)]); + $this->assertSame(ListTable::MAX_SEARCH_LENGTH, strlen($result[0]['value'])); + } + + public function test_prepare_items_passes_search_to_count_and_find(): void { + $expected = [ + [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => 'GetPosts', + ], + ]; + $this->log_service->shouldReceive('count_entities_by_where')->once()->with($expected)->andReturn(1); + $this->log_service->shouldReceive('find_entities_by_where')->once()->with(Mockery::on(static function ($args) use ($expected) { + return $args['where'] === $expected; + }))->andReturn([]); + + $_REQUEST = ['s' => 'GetPosts']; + $this->list_table->prepare_items(); + $_REQUEST = []; + + $this->assertSame([], $this->list_table->items); + } + public function test_prepare_items_sets_pagination_args(): void { $this->log_service->shouldReceive('count_entities_by_where')->andReturn(50); $this->log_service->shouldReceive('find_entities_by_where')->andReturn([]); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php index a509575d8..dc2cd6a55 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php @@ -189,11 +189,93 @@ public function test_get_redirect_url_constructs_correct_url(): void { $this->assertStringNotContainsString('level_filter', $url); $this->assertEquals( menu_page_url(ViewLogsPage::ADMIN_PAGE_SLUG, false) . - '&start_date=2025-01-01 00:00:00&end_date=2025-12-31 23:59:59&orderby=id&order=ASC', + '&start_date=2025-01-01%2000%3A00%3A00&end_date=2025-12-31%2023%3A59%3A59&orderby=id&order=ASC', $url ); } + public function test_get_redirect_url_includes_search(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + $_POST['s'] = 'GetPosts'; + $_POST['level_filter'] = 'error'; + + $url = $instance->get_redirect_url(); + $this->assertEquals( + menu_page_url(ViewLogsPage::ADMIN_PAGE_SLUG, false) . '&level_filter=error&s=GetPosts', + $url + ); + + unset($_POST['s'], $_POST['level_filter']); + } + + public function test_get_redirect_url_encodes_search(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + $_POST['s'] = 'query GetPost($id: ID!) { post(id: $id) { title } } & #more'; + + $url = $instance->get_redirect_url(); + $query = []; + parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query); + $this->assertSame($_POST['s'], $query['s']); + $this->assertSame($url, wp_sanitize_redirect($url), 'The URL should survive wp_safe_redirect() unchanged.'); + + unset($_POST['s']); + } + + public function test_get_redirect_url_keeps_page_only_when_filters_unchanged(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + // Typing a page number keeps the current search. + $_GET['s'] = 'GetPosts'; + $_POST['s'] = 'GetPosts'; + $_POST['paged'] = '3'; + $this->assertStringContainsString('paged=3', $instance->get_redirect_url()); + + // A new search starts from the first page. + $_POST['s'] = 'GetMenus'; + $this->assertStringNotContainsString('paged', $instance->get_redirect_url()); + + unset($_GET['s'], $_POST['s'], $_POST['paged']); + } + + public function test_process_filters_redirect_skips_bulk_actions(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + + $_POST['wpgraphql_logging_nonce'] = wp_create_nonce('wpgraphql_logging_filter'); + $_POST['action'] = 'delete'; + $_POST['action2'] = '-1'; + + // Would redirect and exit if the bulk action was not detected. + $instance->process_filters_redirect(); + $this->assertTrue(true); + + unset($_POST['wpgraphql_logging_nonce'], $_POST['action'], $_POST['action2']); + } + + public function test_is_bulk_action_request(): void { + $this->set_as_admin(); + $instance = ViewLogsPage::init(); + $method = (new \ReflectionClass($instance))->getMethod('is_bulk_action_request'); + $method->setAccessible(true); + + // Both dropdowns left on the placeholder, as when filtering or searching. + $_POST['action'] = '-1'; + $_POST['action2'] = '-1'; + $this->assertFalse($method->invoke($instance)); + + // The bottom dropdown is used. + $_POST['action2'] = 'delete_all'; + $this->assertTrue($method->invoke($instance)); + + unset($_POST['action'], $_POST['action2']); + $this->assertFalse($method->invoke($instance)); + } + public function test_process_log_download_dies_without_nonce(): void { $this->set_as_admin(); $instance = ViewLogsPage::init(); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php index 92ef409de..4f7398765 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php @@ -8,6 +8,7 @@ use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity; use WPGraphQL\Logging\Logger\Api\LogEntityInterface; +use WPGraphQL\Logging\Logger\Api\LogServiceInterface; /** * Test for the WordPressDatabaseLogService @@ -184,4 +185,87 @@ public function test_delete_log_older_than(): void $logs = $this->log_service->find_entities_by_where(); $this->assertCount(0, $logs); } + + public function insert_search_data(): void + { + $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'WPGraphQL Response', ['query' => 'query GetPosts { posts { nodes { title } } }']); + $this->log_service->create_log_entity('wpgraphql_logging', 400, 'ERROR', 'WPGraphQL Response with Errors', ['query' => 'query GetMenus { menus { nodes { name } } }']); + $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'Discount 100% applied', ['variables' => ['name' => 'GetPosts']]); + } + + private function search(string $term, array $where = []): array + { + $where[] = [ + 'column' => LogServiceInterface::SEARCH_COLUMN, + 'operator' => 'LIKE', + 'value' => $term, + ]; + return $this->log_service->find_entities_by_where(['where' => $where]); + } + + public function test_search_matches_message(): void + { + $this->insert_search_data(); + $logs = $this->search('with Errors'); + $this->assertCount(1, $logs); + $this->assertEquals('WPGraphQL Response with Errors', $logs[0]->get_message()); + } + + public function test_search_matches_query_case_insensitively(): void + { + $this->insert_search_data(); + $logs = $this->search('getposts'); + $this->assertCount(1, $logs, 'Only the query should match, not variables in the context.'); + $this->assertEquals('WPGraphQL Response', $logs[0]->get_message()); + } + + public function test_search_escapes_like_wildcards(): void + { + $this->insert_search_data(); + $this->assertCount(1, $this->search('100%')); + $this->assertCount(0, $this->search('_iscount')); + } + + public function test_search_matches_quotes_and_backslashes_in_query(): void + { + $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'WPGraphQL Response', ['query' => 'query { posts(where: {search: "it\'s a \\\\ test"}) { nodes { id } } }']); + + // The query is unquoted from the JSON before matching, so its quotes and backslashes are not JSON-escaped. + $this->assertCount(1, $this->search('search: "it\'s')); + $this->assertCount(1, $this->search('a \\\\ test')); + $this->assertCount(0, $this->search('a \\\\\\\\ test')); + } + + public function test_search_composes_with_other_conditions_and_count(): void + { + $this->insert_search_data(); + $level = [ + 'column' => 'level_name', + 'operator' => '=', + 'value' => 'ERROR', + ]; + $this->assertCount(2, $this->search('WPGraphQL Response')); + $logs = $this->search('WPGraphQL Response', [$level]); + $this->assertCount(1, $logs); + $this->assertEquals('ERROR', $logs[0]->get_level_name()); + + $where = [ + $level, + ['column' => LogServiceInterface::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => 'menus'], + ]; + $this->assertEquals(1, $this->log_service->count_entities_by_where($where)); + $where[1]['value'] = 'GetPosts'; + $this->assertEquals(0, $this->log_service->count_entities_by_where($where)); + } + + public function test_search_ignores_empty_term_and_unsupported_operator(): void + { + $this->insert_search_data(); + $this->assertCount(3, $this->search(' ')); + + $logs = $this->log_service->find_entities_by_where(['where' => [ + ['column' => LogServiceInterface::SEARCH_COLUMN, 'operator' => '=', 'value' => 'GetPosts'], + ]]); + $this->assertCount(3, $logs); + } }