Skip to content

test: add comprehensive test suite (~1,266 tests)#1172

Merged
danieliser merged 5 commits intodevelopfrom
testing
Mar 25, 2026
Merged

test: add comprehensive test suite (~1,266 tests)#1172
danieliser merged 5 commits intodevelopfrom
testing

Conversation

@danieliser
Copy link
Copy Markdown
Member

@danieliser danieliser commented Feb 6, 2026

Summary

  • Adds 866 PHP tests (818 passing, 48 skipped) across 30 test files using WP_UnitTestCase
  • Adds 403 JS/TS tests (all passing) across 24 test suites using Jest
  • Documents 12 bugs discovered by tests in claudedocs/BUGS-FOUND-BY-TESTS.md
  • Adds PHPUnit/Mockery as dev dependencies (composer.json)
  • Includes gap analysis document (claudedocs/testing-gap-analysis.md)

What's covered

PHP (tests/php/tests/)

  • Utils: Array, Sanitize, Format, Fields, Options, general utilities
  • Admin: Settings, Popups list table, Onboarding
  • Models: CallToAction
  • Services: Options, Logging, FormConversionTracking, LinkClickTracking
  • DB: Subscribers
  • REST API: Connect, ObjectSearch, License (skipped), Webhooks (skipped)
  • Core: Analytics, Conditions, Condition Callbacks, Cookies, Triggers

JS/TS (packages/*/src/tests/)

  • core-data: Popup/CTA/License/Settings reducers and selectors, popup validation
  • registry: Full registry lifecycle tests
  • utils: omit, pick, clamp, debug, noop
  • components: URL control (is-url-like)

Bugs documented (not fixed in this PR)

Tests assert current (buggy) behavior with BUG: prefixed test names. See claudedocs/BUGS-FOUND-BY-TESTS.md for full details:

  1. PURGE_RECORD/PURGE_RECORDS string/number key mismatch (memory leak)
  2. RECEIVE_ERROR state mutation (Redux immutability violation)
  3. omit() picks instead of omitting
  4. validatePopup empty title bypass
  5. Analytics track() missing event key (PHP 8.x warning)
  6. Options::update_many() unset/re-set dead code
  7. parse_fields() default parameter ValueError on PHP 8.x
  8. ObjectSearch negative SQL LIMIT without paged param
  9. Core is final (License REST untestable)

Skipped tests (48)

  • PUM_Admin_Settings: dist/assets/site.css not built in test env
  • Test_License_REST_Endpoints: Core class is final, can't mock
  • Test_Webhook_REST_Endpoints: Pro-only feature
  • Analytics: Known PHP 8.x undefined array key bug

Test plan

  • Jest: 403/403 passing
  • PHPUnit: 866 tests (818 pass, 48 skip, 0 errors, 0 failures)
  • CI pipeline verification
  • CodeRabbit review

Summary by CodeRabbit

  • Tests

    • Large expansion of PHP and JS unit/integration/E2E tests covering reducers, selectors, validators, models, services, registries, utilities, REST controllers and analytics; added Jest test config.
  • Documentation

    • Added a testing gap analysis with prioritized roadmap.
    • Added a catalog of issues discovered while writing/validating tests.
  • Chores

    • Adjusted PHP test bootstrap/config and added PHPUnit polyfills and related test-runner config changes.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 6, 2026

Walkthrough

Adds extensive JS/TS and PHP unit tests, two test-documents cataloging bugs and a testing roadmap, and test-tooling adjustments (PHPUnit downgrade + polyfills, Jest mapping). No production API or runtime code is changed; the diff is test/docs/config-only.

Changes

Cohort / File(s) Summary
Documentation
claudedocs/BUGS-FOUND-BY-TESTS.md, claudedocs/testing-gap-analysis.md
Adds a catalog of 13 test-discovered issues and a prioritized testing gap analysis with phased roadmap and infrastructure checklist.
Test tooling & config
composer.json, tests/php/config/bootstrap.php, tests/php/phpunit.xml, tests/unit/jest.config.js
Adjusts dev deps (phpunit version change, adds yoast polyfills), defines polyfill path in PHP bootstrap, alters phpunit discovery/coverage settings, and adds Jest moduleNameMapper for package aliases.
JS/TS unit tests — utilities & components
packages/utils/src/lib/__tests__/*, packages/components/src/lib/url-control/__tests__/is-url-like.test.ts, packages/registry/src/__tests__/index.test.ts
Adds many Jest tests for utilities (clamp, debug, noop, omit/pick, clamp, etc.), url heuristics, and registry behavior; several tests encode existing buggy behavior (e.g., omit acting like pick).
JS/TS unit tests — core-data
packages/core-data/src/.../__tests__/*
Large suites for reducers, selectors, and validation across call-to-actions, popups, license, and settings covering edit history, undo/redo, resolution states, queries, and error paths; tests also surface string-vs-number key and immutability issues.
PHP unit tests — analytics, services, logging
tests/php/tests/FormConversionTracking_Test.php, tests/php/tests/LinkClickTracking_Test.php, tests/php/tests/PUM_Analytics*.php, tests/php/tests/Logging_Service_Test.php
New/expanded PHPUnit suites for analytics, form/link tracking services, and logging with edge-case assertions and action/option interactions.
PHP unit tests — admin, models, options, triggers
tests/php/tests/PUM_Admin_*.php, tests/php/tests/PUM_Models_CallToAction_Test.php, tests/php/tests/PUM_Services_Options_Test.php, tests/php/tests/PUM_Triggers_Test.php
Adds tests for admin field parsing/sanitization, CTA model behavior (UUIDs/event counts), Options service (get/update/remap/merge), and trigger registry lifecycle and defaults.
PHP unit tests — conditions, cookies, DB & utilities
tests/php/tests/PUM_Condition*, tests/php/tests/PUM_Cookies_Test.php, tests/php/tests/PUM_DB_Subscribers_Test.php, tests/php/tests/PUM_Utils_*.php
Extensive suites for condition callbacks/registry/helpers, cookie registry, DB subscribers (CRUD, serialization, migration), and many utility modules (array, fields, format, sanitize, options).
PHP REST API tests
tests/php/tests/REST_Connect_Test.php, tests/php/tests/REST_ObjectSearch_Test.php, tests/php/tests/RestAPI/Test_*.php
Adds REST controller tests for route registration, argument/schema validation, permission checks, object-search behaviors, and runtime guards/skips for unmockable or Pro-only classes.
Test tidying
tests/php/tests/test-pum_utils_array.php (deleted), tests/php/tests/PUM_Utils_ArrayTest.php
Removes an older duplicate test file and replaces it with consolidated, modernized PHPUnit tests under PUM_Utils_ArrayTest.php.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding a comprehensive test suite of approximately 1,266 tests across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch testing

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/php/tests/RestAPI/Test_Webhook_REST_Endpoints.php (1)

64-74: ⚠️ Potential issue | 🟡 Minor

$_SERVER modifications not cleaned up after test.

Lines 66-67 set $_SERVER['HTTP_USER_AGENT'] and $_SERVER['HTTP_AUTHORIZATION'] but these are never restored. This could leak state into subsequent tests. Consider saving/restoring the original values, or unsetting them in tearDown.

Proposed fix
+	/**
+	 * Original SERVER values to restore.
+	 */
+	private $original_server = [];
+
 	public function tearDown(): void {
 		global $wp_rest_server;
 		$wp_rest_server = null;
+
+		// Restore original $_SERVER values.
+		foreach ( $this->original_server as $key => $value ) {
+			if ( null === $value ) {
+				unset( $_SERVER[ $key ] );
+			} else {
+				$_SERVER[ $key ] = $value;
+			}
+		}
+		$this->original_server = [];
 
 		parent::tearDown();
 	}

And in the test method:

 	public function test_webhook_verify_endpoint_security() {
+		$this->original_server['HTTP_USER_AGENT']    = $_SERVER['HTTP_USER_AGENT'] ?? null;
+		$this->original_server['HTTP_AUTHORIZATION'] = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
+
 		$_SERVER['HTTP_USER_AGENT']    = 'PopupMakerUpgrader';
 		$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer test-token';
🤖 Fix all issues with AI agents
In `@claudedocs/BUGS-FOUND-BY-TESTS.md`:
- Around line 59-72: Add a short note to Bug `#4`'s fix section stating that the
same empty-title validation bug appears in CTA validation at
packages/core-data/src/call-to-actions/validation.ts where the condition
callToAction.title && !callToAction.title?.length wrongly treats empty strings
as present; reference that symbol and suggest the same remediation (change the
conditional to properly detect empty titles or normalize/trim title before
checking) so maintainers know to apply the fix to both popup and CTA validation.

In `@packages/core-data/src/call-to-actions/__tests__/validation.test.ts`:
- Around line 47-57: The test name is misleading: update the test description
for the case that asserts validateCallToAction returns true for an empty-string
title; locate the test that calls validateCallToAction (the it( 'returns error
for CTA with empty string title', ...) block) and change the string to
accurately reflect the behavior (e.g., "returns true for CTA with empty string
title" or "treats empty string title as valid") so the name matches the
assertion and inline comment.

In `@packages/core-data/src/popups/__tests__/reducer.test.ts`:
- Around line 534-552: The test for ACTION_TYPES.INVALIDATE_RESOLUTION is
ineffective because resolutionState.getPopup is initialized as a flat object
without an ID key; update the test's existing state so resolutionState.getPopup
contains an ID-keyed entry (e.g., getPopup: { 1: { status:
DispatchStatus.Success } }) before calling reducer(reducer, { type:
ACTION_TYPES.INVALIDATE_RESOLUTION, payload: { id: 1, operation: 'getPopup' }
}), then assert that (state.resolutionState.getPopup as any)[1] is undefined to
verify the reducer actually removed the ID entry; reference symbols: reducer,
ACTION_TYPES.INVALIDATE_RESOLUTION, initialState, State,
resolutionState.getPopup, DispatchStatus.Success.

In `@packages/core-data/src/settings/__tests__/reducer.test.ts`:
- Around line 262-282: The test for INVALIDATE_RESOLUTION is asserting a key
that was never set, so update the test setup to create a keyed resolution before
dispatching: modify the existing state’s resolutionState.fetchSettings to
include a numeric key (e.g., 1: { status: DispatchStatus.Success }) so the
reducer(INVALIDATE_RESOLUTION) actually removes that entry; then assert that
(state.resolutionState.fetchSettings as any)[1] is undefined (or alternatively
assert that resolutionState.fetchSettings is cleared) — locate this in the test
around reducer, ACTION_TYPES.INVALIDATE_RESOLUTION, and
resolutionState.fetchSettings.

In `@tests/php/tests/PUM_Admin_Settings_Test.php`:
- Around line 397-404: The test test_sanitize_objects_invalid_json currently
only checks the key exists; change it to assert the exact value and type
returned by PUM_Admin_Settings::sanitize_objects for invalid JSON (i.e., replace
the assertArrayHasKey with an assertion like assertSame($expected,
$result['key1']) where $expected is the actual output produced by
sanitize_objects for the 'not valid json at all' input) so the test pins the
concrete behavior rather than passing vacuously.

In `@tests/php/tests/PUM_ConditionCallbacks_Test.php`:
- Around line 340-360: The test test_post_type_index_modifier_on_archive
registers a custom post type 'pum_test_cpt' via register_post_type but never
unregisters it, leaking into global $wp_post_types; add a matching
unregister_post_type('pum_test_cpt') to clean up (either at the end of
test_post_type_index_modifier_on_archive or in the test class tearDown) so the
registration from register_post_type('pum_test_cpt', ...) is removed after the
test runs.

In `@tests/php/tests/PUM_DB_Subscribers_Test.php`:
- Around line 630-631: Remove the unused variable assignment or assert its
value: either drop "$id1 =" and just call $this->db->insert(...) if the inserted
ID isn't needed, or keep "$id1 = $this->db->insert(...)" and add an assertion
that uses $id1 (for example asserting it's an integer > 0 or equals the ID
returned by a subsequent query) so the test uses the returned ID from
PUM_DB_Subscribers_Test's call to $this->db->insert.
- Around line 390-395: The test names test_update_negative_id_returns_false and
test_delete_negative_id_returns_false are misleading because the assertions
expect true (due to absint(-5) -> 5); rename the methods to
test_update_negative_id_returns_true and test_delete_negative_id_returns_true
(or alternatively change assertTrue to assertFalse if you intend to test a false
result), and update any related docblock/comments to match; locate the test
methods by their names (test_update_negative_id_returns_false,
test_delete_negative_id_returns_false) and update the method name and any
references accordingly to keep behavior and names consistent with absint()
semantics.
🧹 Nitpick comments (34)
tests/php/tests/PUM_Cookies_Test.php (2)

314-322: Unused $id variable in foreach loop.

The static analysis tool flagged $id as unused on line 319. Since you're only validating the values, use $_ or just omit the key.

🔧 Suggested fix
-		foreach ( $list as $id => $name ) {
+		foreach ( $list as $name ) {
 			$this->assertIsString( $name, 'Cookie name should be a string.' );
 		}

360-411: Filter cleanup won't run if an assertion fails mid-test.

If any assertion before remove_all_filters() throws, the filter persists into subsequent tests. This is unlikely to cause issues here since setUp resets the singleton, but for robustness you could move filter removal to tearDown or accept the minor risk.

tests/php/tests/PUM_DB_Subscribers_Test.php (2)

671-674: Duplicate of test_table_name_includes_prefix (line 31).

test_table_name_method is identical in logic and assertion to test_table_name_includes_prefix. Consider removing one.


23-26: Consider calling create_table() once in setUp() to reduce repetition.

Nearly every test method begins with $this->db->create_table(). Moving it into setUp() (which already instantiates the DB object) would eliminate ~30 duplicate calls. The dedicated test_create_table_* tests can still call it explicitly.

Suggested change
 public function setUp(): void {
     parent::setUp();
     $this->db = new PUM_DB_Subscribers();
+    $this->db->create_table();
 }
packages/utils/src/lib/__tests__/debug.test.ts (3)

8-15: Dead test infrastructure and any type usage.

  1. storageListeners is populated but never read or invoked in any test — it's dead code along with the origAddEventListener reference (also never used to restore or delegate).
  2. The handler parameter uses any, which violates the TypeScript coding guideline.
  3. Because origAddEventListener is never called as a fallback, all non-'storage' event registrations during module loading are silently dropped, which could mask issues.

Consider either removing this infrastructure entirely (if storage-event testing isn't needed yet) or wiring it up properly:

Proposed cleanup
-// Mock window.addEventListener to capture the storage listener.
-const storageListeners: Array< () => void > = [];
-const origAddEventListener = window.addEventListener;
-window.addEventListener = jest.fn( ( event: string, handler: any ) => {
-	if ( event === 'storage' ) {
-		storageListeners.push( handler );
-	}
-} );
+// Mock window.addEventListener to capture the storage listener while
+// preserving default behavior for other events.
+const storageListeners: Array< ( e: StorageEvent ) => void > = [];
+const origAddEventListener = window.addEventListener.bind( window );
+window.addEventListener = jest.fn(
+	( event: string, handler: EventListenerOrEventListenerObject ) => {
+		if ( event === 'storage' ) {
+			storageListeners.push(
+				handler as ( e: StorageEvent ) => void
+			);
+		} else {
+			origAddEventListener( event, handler );
+		}
+	}
+) as typeof window.addEventListener;

As per coding guidelines: "Do not use any type in TypeScript; use unknown for dynamic values outside of the type system, or find/define the type."


111-137: Consider adding a call-count assertion before inspecting mock.calls.

The selector, action, resolver, and state tests jump straight to checking mock.calls[0][0] without first asserting toHaveBeenCalledTimes(1) (as done in the component and effect tests above). If groupCollapsed isn't called, mock.calls[0] is undefined and the test fails with a confusing TypeError instead of a clear assertion message.

Example for one test (apply similarly to others)
 it( 'logs selector debug messages', () => {
 	debugModule.debug.selector( 'getPopups' );
+	expect( consoleSpy.groupCollapsed ).toHaveBeenCalledTimes( 1 );
 	expect( consoleSpy.groupCollapsed.mock.calls[ 0 ][ 0 ] ).toMatch(
 		/Select:getPopups/
 	);
 } );

192-197: Comment/regex mismatch on timestamp format.

The comment on line 195 says HH:MM:SS.mmmZ but the regex only validates [HH:MM:SS — it doesn't check milliseconds or the trailing Z. If the format assertion is intentionally loose, consider updating the comment; if milliseconds matter, tighten the regex.

tests/php/tests/Logging_Service_Test.php (2)

17-42: Consider extracting repeated setup into setUp() to reduce boilerplate.

Every test method repeats create_mock_container()create_logging_instance()set_private_property(... 'content' ...)set_private_property(... 'is_writable' ...). Promoting $service (and optionally $container) to instance properties initialized in setUp() would cut significant duplication.

♻️ Suggested refactor
 class Logging_Service_Test extends WP_UnitTestCase {
+
+	/**
+	 * `@var` Logging
+	 */
+	private $service;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$container     = $this->create_mock_container();
+		$this->service = $this->create_logging_instance( $container );
+	}
+
 	/**
 	 * Test disabled returns true when PUM_DISABLE_LOGGING is defined.
 	 */
 	public function test_disabled_returns_true_with_constant() {
-		$container = $this->create_mock_container();
-		$service   = $this->create_logging_instance( $container );
-
 		// Without the constant defined, disabled() should return false.
-		$this->assertFalse( $service->disabled(), 'Should not be disabled without the constant.' );
+		$this->assertFalse( $this->service->disabled(), 'Should not be disabled without the constant.' );
 	}

Then each test would use $this->service directly, only calling set_private_property for the specific state it needs.


243-262: Coding guidelines recommend Mockery for PHP mocking.

The anonymous class works fine here, but the project guidelines specify using Mockery for test mocks. This is a low-priority alignment item since the anonymous class is simple and readable.

As per coding guidelines: "Write PHP unit tests using PHPUnit in the tests/php/tests/ directory and use Mockery for PHP mocking".

tests/php/tests/PUM_Admin_Settings_Test.php (2)

116-125: Use markTestSkipped instead of assertTrue(true) for conditional branches.

The assertTrue(true) pattern appears here and in several other tests (lines 164, 212, 243, 273, 289) as a workaround for PHPUnit's "risky test" warning. This silently passes and hides the fact that nothing was actually tested. markTestSkipped() is more appropriate — it signals clearly in test output that the assertion path wasn't exercised, making it easier to notice when expected fields disappear.

Example fix (apply same pattern to all occurrences)
 		if ( isset( $defaults['body_padding_override'] ) ) {
 			$this->assertEquals( '15px', $defaults['body_padding_override'], 'body_padding_override default should be 15px.' );
 		} else {
-			// If the field doesn't exist, add an assertion to prevent risky test warning.
-			$this->assertTrue( true, 'Field body_padding_override not found in defaults.' );
+			$this->markTestSkipped( 'Field body_padding_override not found in defaults.' );
 		}

219-275: Duplicated license field lookup logic across two tests.

Both test_sanitize_settings_license_key_star_mask_preserved and test_sanitize_settings_license_key_new_value contain identical loops to find the first license_key field. Consider extracting a small private helper (e.g., find_first_field_by_type) to reduce duplication.

tests/php/tests/PUM_Models_CallToAction_Test.php (1)

229-236: Consider asserting the actual default description text.

The relevant code snippet from CallToAction.php (line 206) shows the default is 'This content is restricted.'. This test only checks non-empty + string, making it unable to catch regressions in the default message. The comment on line 234 already knows the expected value.

Suggested improvement
 	public function test_get_description_returns_default_message() {
 		$cta = $this->create_cta();
 
 		$desc = $cta->get_description();
-		$this->assertNotEmpty( $desc );
-		// Default message is "This content is restricted." when get_the_excerpt() is empty.
-		$this->assertIsString( $desc );
+		$this->assertSame( 'This content is restricted.', $desc );
 	}
tests/php/tests/RestAPI/Test_License_REST_Endpoints.php (1)

60-101: Dead code after markTestSkipped — consider documenting intent more clearly.

All code after markTestSkipped() on line 64 is unreachable. While the tests are well-structured for future use, note that lines 85-90 define a global plugin() function which could collide with the real plugin() function or other test classes if the skip is ever removed. Consider using a namespace or a different mocking strategy when re-enabling.

tests/php/phpunit.xml (2)

2-9: convertErrorsToExceptions, convertNoticesToExceptions, convertWarningsToExceptions are deprecated in PHPUnit 9.5+.

These attributes were removed in PHPUnit 10 and generate deprecation notices in 9.5+. They can be safely removed since they default to true in PHPUnit 9.


15-25: <filter><whitelist> generates deprecation warnings in PHPUnit 9.6 — migrate to <coverage> syntax.

The old XML schema is deprecated (PHPUnit 9.5+). While it still works, it emits deprecation warnings in 9.6. Replace it with the modern <coverage> syntax using <include> and <exclude> elements:

Proposed migration to `` syntax
-	<filter>
-		<whitelist processUncoveredFilesFromWhitelist="false">
-			<directory suffix=".php">../../classes/</directory>
-			<directory suffix=".php">../../includes/</directory>
-			<exclude>
-				<directory suffix=".php">../../includes/legacy/</directory>
-				<directory suffix=".php">../../vendor/</directory>
-				<directory suffix=".php">../../vendor-prefixed/</directory>
-			</exclude>
-		</whitelist>
-	</filter>
+	<coverage processUncoveredFiles="false">
+		<include>
+			<directory suffix=".php">../../classes/</directory>
+			<directory suffix=".php">../../includes/</directory>
+		</include>
+		<exclude>
+			<directory suffix=".php">../../includes/legacy/</directory>
+			<directory suffix=".php">../../vendor/</directory>
+			<directory suffix=".php">../../vendor-prefixed/</directory>
+		</exclude>
+	</coverage>
packages/components/src/lib/url-control/__tests__/is-url-like.test.ts (2)

1-10: Mock of @wordpress/url may diverge from the real isURL behavior.

The native URL constructor accepts schemes like ftp: and mailto: that WordPress's isURL may reject. If the real isURL has stricter validation, edge-case tests (lines 78–84) could pass here but fail in production. Consider verifying the real isURL behavior for these protocols, or add a comment noting the approximation.


12-90: Good coverage of URL-like detection scenarios.

Tests cover full URLs, www-prefixed strings, hash links, non-URL strings, and edge cases. The use of toBeTruthy()/toBeFalsy() vs toBe(true)/toBe(false) is intentionally inconsistent — consider using strict boolean checks throughout for consistency, since isURLLike returns boolean.

claudedocs/testing-gap-analysis.md (1)

195-206: Add language specifiers to fenced code blocks.

The fenced code blocks at lines 195, 213, and 231 lack a language identifier, which triggers MD040 warnings and prevents syntax highlighting. Adding a language (e.g., ```text or ```plaintext) would resolve this.

packages/core-data/src/call-to-actions/__tests__/reducer.test.ts (1)

25-41: Pervasive use of as any in mock helpers.

The mockCta and mockEditable helpers cast return values to any, bypassing type safety. While pragmatic for tests, the coding guidelines prohibit the any type. Consider defining minimal type-compatible objects or using Partial<CallToAction<'edit'>> to preserve some type checking.

That said, this is a low-priority concern in test code and can be deferred. As per coding guidelines: "Do not use any type in TypeScript; use unknown for dynamic values outside of the type system, or find/define the type."

claudedocs/BUGS-FOUND-BY-TESTS.md (1)

207-210: Bug #12 is explicitly a duplicate of Bug #6 — consider removing.

This entry adds no new information beyond "confirmed by Batch 2." Consolidating it into Bug #6 as a note would keep the document cleaner.

tests/php/tests/PUM_ConditionCallbacks_Test.php (1)

990-1004: Custom taxonomy genre registered in multiple tests without cleanup.

register_taxonomy('genre', ...) is called in five separate test methods. While WP_UnitTestCase handles DB rollback, the taxonomy registration persists in the global $wp_taxonomies. Consider registering it once in setUp() or adding cleanup in a tearDown() method to avoid inter-test leakage.

The same applies to the color taxonomy registered in lines 1288-1361.

Also applies to: 1009-1021, 1030-1046, 1051-1067, 1072-1088

packages/registry/src/__tests__/index.test.ts (1)

199-251: Subscriber tests are tightly coupled to useSyncExternalStore mock internals.

The tests at lines 200-251 extract the subscribe callback by overriding the useSyncExternalStore mock from inside the test and calling registry.useItems() to trigger the subscription. This works but is fragile — any refactor of how the registry manages subscriptions (e.g., exposing subscribe directly) would break these tests without changing behavior.

This is acceptable for now since the registry's public API doesn't directly expose subscribe/unsubscribe, but worth noting for future maintainability.

tests/php/tests/PUM_Utils_Options_Test.php (1)

424-433: Unused $result variable — either assert on it or drop the assignment.

PHPMD correctly flags $result as unused. Since the test verifies that existing keys survive a delete of a non-existent key, you likely want to assert the return value too.

Proposed fix
-		$result = PUM_Utils_Options::delete( 'nonexistent' );
+		PUM_Utils_Options::delete( 'nonexistent' );

Or, if you want to document the expected return:

-		$result = PUM_Utils_Options::delete( 'nonexistent' );
-
-		$all = get_option( 'popmake_settings' );
+		$result = PUM_Utils_Options::delete( 'nonexistent' );
+		// update_option returns false when the value is unchanged.
+		$this->assertFalse( $result );
+		$all = get_option( 'popmake_settings' );
packages/core-data/src/call-to-actions/__tests__/selectors.test.ts (1)

50-65: as any casts on mock helpers violate the TypeScript coding guideline.

The project guideline states: "Do not use any type in TypeScript." Consider using Partial<CallToAction<'edit'>> (or the appropriate type) with an as cast to the full type, or define a lightweight test-only type. This is a low-priority nit for test code.

Example
-const mockCta = ( id: number, title = `CTA ${ id }` ) =>
-	( {
-		id,
-		title,
-		status: 'publish',
-		settings: { type: 'link', url: '' },
-	} ) as any;
+const mockCta = ( id: number, title = `CTA ${ id }` ) =>
+	( {
+		id,
+		title,
+		status: 'publish',
+		settings: { type: 'link', url: '' },
+	} ) as unknown as CallToAction< 'edit' >;

As per coding guidelines, "Do not use any type in TypeScript; use unknown for dynamic values outside of the type system, or find/define the type."

tests/php/tests/LinkClickTracking_Test.php (1)

43-48: Using stdClass as a container may be fragile if the Service base class evolves.

Currently works, but if the Service constructor or LinkClickTracking::init() later accesses $container->get(...), stdClass will cause a fatal error. Consider using a minimal anonymous class with a get() stub (similar to the pattern in PUM_Services_Options_Test), which would be more resilient to future changes.

tests/php/tests/PUM_Triggers_Test.php (1)

303-311: Consider using $id in the assertion message for better test diagnostics.

PHPMD flags $id as unused. While the foreach structure implicitly validates keys, using $id in the assertion message would both silence the warning and improve failure diagnostics.

Proposed fix
 		foreach ( $list as $id => $name ) {
-			$this->assertIsString( $name, 'Trigger name should be a string.' );
+			$this->assertIsString( $name, "Trigger '$id' name should be a string." );
 		}
tests/php/tests/PUM_Conditions_Test.php (1)

362-369: Same $id unused variable pattern — use it in the assertion message.

Same as the PUM_Triggers_Test feedback: using $id in the assertion message improves diagnostics and silences the PHPMD warning.

Proposed fix
 		foreach ( $conditions as $id => $name ) {
-			$this->assertIsString( $name, 'Condition name should be a string.' );
+			$this->assertIsString( $name, "Condition '$id' name should be a string." );
 		}
tests/php/tests/PUM_Utils_Fields_Test.php (1)

709-717: Minor: suppress or remove the unused $output variable.

$output is captured but never asserted on. You can call ob_get_clean() without assignment to silence the PHPMD warning.

♻️ Suggested cleanup
 		ob_start();
 		PUM_Utils_Fields::render_field( [ 'type' => 'nonexistent_type_xyz' ] );
-		$output = ob_get_clean();
+		ob_get_clean();
tests/php/tests/PUM_Utils_ArrayTest.php (1)

1148-1153: Simplify the convoluted assertion on Line 1149.

The <=> 0 ? -1 : 1 expression obscures the intent. The next two lines (1150–1151) already assert the same behavior more clearly. Consider removing or simplifying Line 1149.

♻️ Suggested simplification
 	public function test_sort_by_sort() {
-		$this->assertSame( -1, PUM_Utils_Array::sort_by_sort( [ 'sort' => 1 ], [ 'sort' => 2 ] ) <=> 0 ? -1 : 1 );
 		$this->assertLessThan( 0, PUM_Utils_Array::sort_by_sort( [ 'sort' => 1 ], [ 'sort' => 10 ] ) );
 		$this->assertGreaterThan( 0, PUM_Utils_Array::sort_by_sort( [ 'sort' => 10 ], [ 'sort' => 1 ] ) );
 		$this->assertSame( 0, PUM_Utils_Array::sort_by_sort( [ 'sort' => 5 ], [ 'sort' => 5 ] ) );
 	}
packages/core-data/src/popups/__tests__/selectors.test.ts (1)

18-30: applyPatch mock only handles single-segment JSON Pointer paths.

patch.path.replace('/', '') removes only the first /, so a path like /settings/display would produce the key settings/display instead of nesting. This is fine for the current tests (all use top-level paths like /title), but worth noting if deeper-path tests are added later.

tests/php/tests/REST_Connect_Test.php (2)

48-60: Extract repeated createPartialMock + get_install_webhook_args to a fixture.

The pattern $this->createPartialMock(Connect::class, []) followed by $controller->get_install_webhook_args() is repeated in nearly every test. Moving the controller and args into setUp properties (e.g., $this->controller and $this->install_args) would cut ~60 lines of boilerplate.

♻️ Proposed refactor
+	/**
+	 * Controller instance.
+	 *
+	 * `@var` Connect
+	 */
+	private $controller;
+
+	/**
+	 * Install webhook args.
+	 *
+	 * `@var` array
+	 */
+	private $install_args;
+
 	public function setUp(): void {
 		parent::setUp();
 		do_action( 'rest_api_init' );
+		$this->controller   = $this->createPartialMock( Connect::class, [] );
+		$this->install_args = $this->controller->get_install_webhook_args();
 	}

Then replace all instances of:

$controller = $this->createPartialMock( Connect::class, [] );
$args       = $controller->get_install_webhook_args();

with references to $this->controller and $this->install_args.


496-516: Mock service setup is also repeated — consider a helper method.

The getMockBuilder(stdClass::class)->addMethods([...])->getMock() + reflection pattern for setting connect_service is repeated in 6 tests (Lines 496, 521, 542, 632, 652, 672). A private helper like createControllerWithMockService() would simplify these.

tests/php/tests/PUM_Analytics_Expanded_Test.php (2)

94-114: Unused closure parameters can be simplified.

The PHPMD hint about unused $popup_id and $args at Line 99 is valid — since neither is used, the closure can be simplified.

♻️ Proposed fix
 		add_action(
 			'pum_analytics_open',
-			function ( $popup_id, $args ) use ( &$fired ) {
+			function () use ( &$fired ) {
 				$fired = true;
 			},
 			10,
 			2
 		);

396-411: Weak assertion in test_analytics_enabled_disabled_by_option.

The test sets disable_analytics to true but only asserts assertIsBool (Line 410) instead of assertFalse. This makes the test nearly a no-op since analytics_enabled() always returns a boolean. Consider investigating how pum_get_option reads the setting so this can assert assertFalse for the expected behavior.

Comment on lines +59 to +72
## 3. `omit.ts`: Implementation Picks Instead of Omitting

**Severity**: High
**Location**: `packages/utils/src/lib/omit.ts`
**Test**: `packages/utils/src/lib/__tests__/omit.test.ts`

**Problem**: The function is typed as `Omit<T, K>` but the implementation copies the specified keys TO the result instead of excluding them FROM the result. It behaves like `pick()`, not `omit()`.

**Impact**: Any code calling `omit(obj, ['a', 'b'])` expecting `a` and `b` to be removed will instead get ONLY `a` and `b`. If callers adapted to the buggy behavior, fixing this will break them.

**Fix**: Either:
1. Fix the implementation to actually omit keys, OR
2. Rename it to `pick` if that's the intended behavior and update the type signature

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Bug #3 (omit.ts): the title validation logic in the fix section of Bug #4 also applies to CTA validation.

Bug #4 documents the empty-title issue for popups, but the same pattern exists in packages/core-data/src/call-to-actions/validation.ts (line 29: callToAction.title && !callToAction.title?.length). Consider adding a note that Bug #4 also affects CTA validation.

🤖 Prompt for AI Agents
In `@claudedocs/BUGS-FOUND-BY-TESTS.md` around lines 59 - 72, Add a short note to
Bug `#4`'s fix section stating that the same empty-title validation bug appears in
CTA validation at packages/core-data/src/call-to-actions/validation.ts where the
condition callToAction.title && !callToAction.title?.length wrongly treats empty
strings as present; reference that symbol and suggest the same remediation
(change the conditional to properly detect empty titles or normalize/trim title
before checking) so maintainers know to apply the fix to both popup and CTA
validation.

Comment on lines +47 to +57
it( 'returns error for CTA with empty string title', () => {
// `callToAction.title` = '' is falsy, so `callToAction.title && !callToAction.title?.length`
// evaluates to '' (falsy) → condition is false → returns true.
// This is the actual behavior of the code.
const result = validateCallToAction( {
id: 1,
title: '',
} as any );

expect( result ).toBe( true );
} );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading test name: says "returns error" but asserts true.

The test is named 'returns error for CTA with empty string title' but the assertion on line 56 expects result to be true (i.e., valid). The inline comment explains the behavior correctly, but the test name is confusing and could mislead future developers.

Suggested name fix
-	it( 'returns error for CTA with empty string title', () => {
+	it( 'BUG: does NOT catch empty string title (returns true)', () => {
🤖 Prompt for AI Agents
In `@packages/core-data/src/call-to-actions/__tests__/validation.test.ts` around
lines 47 - 57, The test name is misleading: update the test description for the
case that asserts validateCallToAction returns true for an empty-string title;
locate the test that calls validateCallToAction (the it( 'returns error for CTA
with empty string title', ...) block) and change the string to accurately
reflect the behavior (e.g., "returns true for CTA with empty string title" or
"treats empty string title as valid") so the name matches the assertion and
inline comment.

Comment thread packages/core-data/src/popups/__tests__/reducer.test.ts
Comment thread packages/core-data/src/settings/__tests__/reducer.test.ts
Comment on lines +397 to +404
public function test_sanitize_objects_invalid_json() {
$input = [
'key1' => 'not valid json at all',
];
$result = PUM_Admin_Settings::sanitize_objects( $input );
// json_decode returns null for invalid json, then object_to_array handles it.
$this->assertArrayHasKey( 'key1', $result );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Weak assertion — test passes regardless of behavior.

This test only asserts the key still exists, but doesn't verify what value sanitize_objects produces for invalid JSON input. If the implementation changes to silently drop the key or return an unexpected type, this test would still pass.

Proposed fix
 	public function test_sanitize_objects_invalid_json() {
 		$input = [
 			'key1' => 'not valid json at all',
 		];
 		$result = PUM_Admin_Settings::sanitize_objects( $input );
-		// json_decode returns null for invalid json, then object_to_array handles it.
-		$this->assertArrayHasKey( 'key1', $result );
+		// json_decode returns null for invalid JSON; document the actual expected behavior.
+		$this->assertArrayHasKey( 'key1', $result, 'Key should still be present after sanitization.' );
+		$this->assertNull( $result['key1'], 'Invalid JSON should result in null after decode.' );
 	}

Adjust the expected value to match whatever sanitize_objects actually returns for invalid JSON — the point is to pin the behavior explicitly.

🤖 Prompt for AI Agents
In `@tests/php/tests/PUM_Admin_Settings_Test.php` around lines 397 - 404, The test
test_sanitize_objects_invalid_json currently only checks the key exists; change
it to assert the exact value and type returned by
PUM_Admin_Settings::sanitize_objects for invalid JSON (i.e., replace the
assertArrayHasKey with an assertion like assertSame($expected, $result['key1'])
where $expected is the actual output produced by sanitize_objects for the 'not
valid json at all' input) so the test pins the concrete behavior rather than
passing vacuously.

Comment on lines +340 to +360
public function test_post_type_index_modifier_on_archive() {
// 'post' type doesn't have a post type archive (uses blog page instead).
// Use a custom post type that has has_archive = true.
register_post_type( 'pum_test_cpt', [
'public' => true,
'has_archive' => true,
] );

$this->factory->post->create( [ 'post_type' => 'pum_test_cpt', 'post_status' => 'publish' ] );

$this->go_to( get_post_type_archive_link( 'pum_test_cpt' ) );

$condition = [
'target' => 'pum_test_cpt_index',
'settings' => [],
];

$result = PUM_ConditionCallbacks::post_type( $condition );

$this->assertTrue( $result, 'post_index should match on a post type archive page.' );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing unregister_post_type() for pum_test_cpt.

The custom post type registered at line 343 is never unregistered, unlike my_cpt (line 758) and book (line 1431). While WP_UnitTestCase rolls back the database, the post type registration persists in the global $wp_post_types array and could leak into subsequent tests.

Suggested fix
 		$this->assertTrue( $result, 'post_index should match on a post type archive page.' );
+
+		unregister_post_type( 'pum_test_cpt' );
 	}
🤖 Prompt for AI Agents
In `@tests/php/tests/PUM_ConditionCallbacks_Test.php` around lines 340 - 360, The
test test_post_type_index_modifier_on_archive registers a custom post type
'pum_test_cpt' via register_post_type but never unregisters it, leaking into
global $wp_post_types; add a matching unregister_post_type('pum_test_cpt') to
clean up (either at the end of test_post_type_index_modifier_on_archive or in
the test class tearDown) so the registration from
register_post_type('pum_test_cpt', ...) is removed after the test runs.

Comment on lines +390 to +395
public function test_update_negative_id_returns_false() {
$this->db->create_table();
$result = $this->db->update( -5, [ 'name' => 'Nope' ] );
// absint(-5) = 5, which is a valid positive ID, so update proceeds.
$this->assertTrue( $result );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading test method name — asserts true but name says returns_false.

The method test_update_negative_id_returns_false actually asserts assertTrue. The docblock and comments correctly describe the absint() behavior, but the method name contradicts the actual assertion. Same issue applies to test_delete_negative_id_returns_false on line 472.

Suggested rename
-	public function test_update_negative_id_returns_false() {
+	public function test_update_negative_id_returns_true_due_to_absint() {

And on line 472:

-	public function test_delete_negative_id_returns_false() {
+	public function test_delete_negative_id_returns_true_due_to_absint() {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function test_update_negative_id_returns_false() {
$this->db->create_table();
$result = $this->db->update( -5, [ 'name' => 'Nope' ] );
// absint(-5) = 5, which is a valid positive ID, so update proceeds.
$this->assertTrue( $result );
}
public function test_update_negative_id_returns_true_due_to_absint() {
$this->db->create_table();
$result = $this->db->update( -5, [ 'name' => 'Nope' ] );
// absint(-5) = 5, which is a valid positive ID, so update proceeds.
$this->assertTrue( $result );
}
🤖 Prompt for AI Agents
In `@tests/php/tests/PUM_DB_Subscribers_Test.php` around lines 390 - 395, The test
names test_update_negative_id_returns_false and
test_delete_negative_id_returns_false are misleading because the assertions
expect true (due to absint(-5) -> 5); rename the methods to
test_update_negative_id_returns_true and test_delete_negative_id_returns_true
(or alternatively change assertTrue to assertFalse if you intend to test a false
result), and update any related docblock/comments to match; locate the test
methods by their names (test_update_negative_id_returns_false,
test_delete_negative_id_returns_false) and update the method name and any
references accordingly to keep behavior and names consistent with absint()
semantics.

Comment on lines +630 to +631
$id1 = $this->db->insert( [ 'email' => 'num1@example.com', 'popup_id' => 42 ] );
$this->db->insert( [ 'email' => 'num2@example.com', 'popup_id' => 99 ] );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused variable $id1.

$id1 is assigned but never referenced. Either remove the assignment or use it in the assertion to make the test more precise (e.g., verifying the matched row's ID).

Suggested fix
-		$id1 = $this->db->insert( [ 'email' => 'num1@example.com', 'popup_id' => 42 ] );
+		$this->db->insert( [ 'email' => 'num1@example.com', 'popup_id' => 42 ] );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$id1 = $this->db->insert( [ 'email' => 'num1@example.com', 'popup_id' => 42 ] );
$this->db->insert( [ 'email' => 'num2@example.com', 'popup_id' => 99 ] );
$this->db->insert( [ 'email' => 'num1@example.com', 'popup_id' => 42 ] );
$this->db->insert( [ 'email' => 'num2@example.com', 'popup_id' => 99 ] );
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 630-630: Avoid unused local variables such as '$id1'. (undefined)

(UnusedLocalVariable)

🤖 Prompt for AI Agents
In `@tests/php/tests/PUM_DB_Subscribers_Test.php` around lines 630 - 631, Remove
the unused variable assignment or assert its value: either drop "$id1 =" and
just call $this->db->insert(...) if the inserted ID isn't needed, or keep "$id1
= $this->db->insert(...)" and add an assertion that uses $id1 (for example
asserting it's an integer > 0 or equals the ID returned by a subsequent query)
so the test uses the returned ID from PUM_DB_Subscribers_Test's call to
$this->db->insert.

danieliser added a commit that referenced this pull request Feb 6, 2026
- Fix  cleanup in Webhook test tearDown
- Replace as any with as unknown as Type across TS test files
- Fix tautological INVALIDATE_RESOLUTION tests (proper per-ID state)
- Modernize phpunit.xml (remove deprecated attrs, filter -> coverage)
- Extract taxonomy registration to setUp/tearDown in ConditionCallbacks
- DRY REST_Connect_Test with setUp fixtures and helper method
- Fix analytics test using wrong option key (pum_settings -> PUM_Utils_Options API)
- Strengthen weak assertions (assertIsBool -> assertFalse, assertTrue(true) -> assertIsString)
- Use $id in assertion messages for better diagnostics
- Remove unused variables and dead code
- Fix toBeTruthy/toBeFalsy -> toBe(true)/toBe(false) for boolean returns
- Consolidate Bug #12 into Bug #6, add code block language specifiers
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@claudedocs/testing-gap-analysis.md`:
- Around line 13-20: The Markdown tables in claudedocs/testing-gap-analysis.md
are missing blank lines before and after each table (MD058); edit the file to
insert a single blank line immediately above and immediately below every table
block (including the table shown and those at ranges 27-36, 37-47, 48-53) so
each table is separated by blank lines from surrounding text or other blocks,
then run markdownlint to confirm MD058 is resolved.
🧹 Nitpick comments (17)
tests/php/tests/RestAPI/Test_Webhook_REST_Endpoints.php (2)

66-76: Overly broad status code assertions may mask server errors.

Accepting 500 alongside expected client-error codes (e.g., 403, 400) means a genuine unhandled exception in the endpoint would still pass the test. A 500 is almost never an intentional "security validation failed" response.

Consider narrowing the expected sets to only the codes the endpoint should return, and treating 500 as a test failure. This applies to all similar assertContains calls in lines 75, 102, 120, 135, 146, 157, and 168.


194-195: Minor inconsistency: uses rest_get_server() instead of $this->server.

All other tests use $this->server to dispatch requests, but this method calls rest_get_server()->get_routes(). They should resolve to the same global instance, so it's not a bug, but using $this->server->get_routes() would be more consistent.

packages/core-data/src/settings/__tests__/selectors.test.ts (1)

141-155: Misleading test name: tests a falsy value, not an unknown key.

The test is named "returns manage_options as default for unknown permissions", but it actually sets a known key (view_block_controls) to false and asserts the fallback. Consider renaming to clarify it tests falsy permission values, e.g. "returns manage_options when permission value is falsy". A separate test for a truly unknown key (not present in the permissions object at all) would improve coverage.

tests/php/tests/PUM_Triggers_Test.php (2)

387-499: Filter cleanup can be skipped if an assertion fails, leaking filters into subsequent tests.

If any assertion throws before the remove_all_filters() call, the anonymous filter persists on the hook for the remaining test run. Move filter cleanup into tearDown() to guarantee execution regardless of test outcome.

Proposed fix
 public function tearDown(): void {
 	PUM_Triggers::$instance = null;
+	remove_all_filters( 'pum_registered_triggers' );
+	remove_all_filters( 'pum_get_triggers' );
+	remove_all_filters( 'pum_trigger_cookie_fields' );
+	remove_all_filters( 'pum_trigger_cookie_field' );
+	remove_all_filters( 'pum_get_trigger_tabs' );
 	parent::tearDown();
 }

Then remove the inline // Clean up. + remove_all_filters(…) calls from each test method body.


265-271: Consider guarding against a missing name key to avoid a PHP notice.

If a trigger is registered without a name key, $trigger['name'] would emit a notice rather than failing the assertion cleanly. An assertArrayHasKey before assertNotEmpty would give a clearer failure message, though this is minor since the production code always sets name.

packages/utils/src/lib/__tests__/debug.test.ts (1)

10-10: window.addEventListener override is never restored.

This direct assignment isn't tracked by jest.restoreAllMocks() (only jest.spyOn instances are). While Jest's jsdom sandbox prevents cross-file leakage, using jest.spyOn instead would be cleaner and allow you to assert that the module registers its storage event listener.

Suggested approach
-window.addEventListener = jest.fn();
+const addEventListenerSpy = jest.spyOn( window, 'addEventListener' ).mockImplementation( () => {} );

Then restore in an afterAll or rely on jest.restoreAllMocks().

tests/php/tests/PUM_ConditionCallbacks_Test.php (1)

1390-1409: Global $post left in a dirty state after edge-case tests.

test_is_post_type_no_global_post sets $post = null and test_is_post_type_non_object_post sets $post = 'not_an_object' without restoring the original value. WP_UnitTestCase doesn't auto-restore $post. This could cause order-dependent failures if tests are reordered or new tests are appended after these.

Suggested fix (example for one test)
 	public function test_is_post_type_no_global_post() {
 		global $post;
+		$original_post = $post;
 		$post = null;
 
 		$result = PUM_ConditionCallbacks::is_post_type( 'post' );
 
 		$this->assertFalse( $result, 'Should return false when global $post is null.' );
+		$post = $original_post;
 	}
tests/php/tests/PUM_Conditions_Test.php (1)

536-543: Conditional assertion weakens the test — the administrator check can be silently skipped.

If $roles is unexpectedly empty, this test passes without verifying anything meaningful. In a WordPress test environment, roles are always initialized, so the guard is unnecessary. Assert non-empty unconditionally.

Proposed fix
 	public function test_allowed_user_roles_returns_roles() {
 		$roles = PUM_Conditions::allowed_user_roles();
 		$this->assertIsArray( $roles );
-		// In a WP test environment, roles should include administrator.
-		if ( ! empty( $roles ) ) {
-			$this->assertArrayHasKey( 'administrator', $roles );
-		}
+		// In a WP test environment, roles should always include administrator.
+		$this->assertNotEmpty( $roles, 'Roles should not be empty in WP test environment.' );
+		$this->assertArrayHasKey( 'administrator', $roles );
 	}
tests/php/tests/PUM_Analytics_Expanded_Test.php (3)

361-374: Inconsistent assertion approach: get_post_meta vs $popup->get_event_count().

Other tests in this file (e.g., lines 52, 69, 87) use $popup->get_event_count('open') to verify counts. This test reaches directly into get_post_meta with a hard-coded meta key popup_open_count. If the meta key ever changes, this test would silently pass with stale data while the object API tests would correctly fail.

Consider using $popup->get_event_count('open') for consistency, or at least re-fetching the popup object to avoid any caching:

Suggested change
-		$count = (int) get_post_meta( $this->popup_id, 'popup_open_count', true );
-		$this->assertEquals( 3, $count, 'Open count should be 3 after three tracks.' );
+		$popup = pum_get_popup( $this->popup_id );
+		$this->assertEquals( 3, $popup->get_event_count( 'open' ), 'Open count should be 3 after three tracks.' );

452-462: Tests correctly document surprising endpoint_absint behavior — consider adding doc comments noting this.

A function named absint that accepts floats (3.14) and negative strings ('-5') as valid is counterintuitive. The tests document this well, but a brief inline note (similar to the BUG: prefix convention used elsewhere in this PR) would help future readers understand this is documenting existing behavior rather than validating expected behavior.


281-287: This test documents a source-code bug but lacks the BUG: prefix.

event_keys('submit') producing 'submited' (missing the doubled 't') is a grammatical bug in the source. Per this PR's convention, tests asserting current buggy behavior should be prefixed with BUG: in the test name (like test_track_with_missing_event_key on line 351). Consider renaming for consistency:

Suggested change
-	public function test_event_keys_custom_event_not_ending_in_e() {
+	public function test_bug_event_keys_custom_event_not_ending_in_e() {
 		$keys = PUM_Analytics::event_keys( 'submit' );
 
 		$this->assertEquals( 'submit', $keys[0], 'First key should be the event name.' );
-		// rtrim('submit', 'e') = 'submit', then 'submit' . 'ed' = 'submited' (source just appends 'ed').
-		$this->assertEquals( 'submited', $keys[1], 'Second key should add ed suffix (no double t).' );
+		// BUG: rtrim('submit', 'e') = 'submit', then 'submit' . 'ed' = 'submited' (source just appends 'ed').
+		$this->assertEquals( 'submited', $keys[1], 'BUG: Second key should add ed suffix (no double t).' );
tests/php/tests/PUM_Utils_Fields_Test.php (1)

722-735: Unused $args parameter in action callback.

The $args parameter in the callback is never read. Since you only check whether the hook fired, the parameter can be dropped.

Proposed fix
-		$callback    = function ( $args ) use ( &$hook_called ) {
+		$callback    = function () use ( &$hook_called ) {
 			$hook_called = true;
 		};
tests/php/tests/PUM_Utils_ArrayTest.php (1)

886-891: Weak assertion — consider verifying the actual result shape.

assertNotFalse only proves the method didn't return false, but doesn't validate the output. If replace_key with a missing key is expected to return the input unchanged, asserting that would be more informative.

Suggested improvement
 	public function test_replace_key_nonexistent() {
 		$input    = [ 'a' => 1 ];
 		$returned = PUM_Utils_Array::replace_key( $input, 'missing', 'new' );
-		// The method does not throw, it will attempt array_combine.
-		$this->assertNotFalse( $returned );
+		// When the old key doesn't exist, the array should remain unchanged.
+		$this->assertIsArray( $returned );
+		$this->assertArrayHasKey( 'a', $returned );
+		$this->assertSame( 1, $returned['a'] );
 	}
packages/core-data/src/call-to-actions/__tests__/selectors.test.ts (2)

69-80: Consider importing initialState from constants instead of manually duplicating the state shape.

The CTA reducer test (same directory) imports initialState from ../constants, but this selector test manually defines emptyState. If the State shape changes (e.g., a new required field is added), this manual definition will silently drift out of sync, potentially causing false test results.

♻️ Suggested change
-// Base empty state matching initialState shape.
-const emptyState: State = {
-	byId: {},
-	allIds: [],
-	queries: {},
-	editorId: undefined,
-	editedEntities: {},
-	editHistory: {},
-	editHistoryIndex: {},
-	resolutionState: {},
-	notices: {},
-	errors: { global: null, byId: {} },
-};
+import { initialState } from '../constants';
+
+// Use the canonical initial state from constants.
+const emptyState: State = initialState;

Note: The initialState import would need to be placed with the other imports after the mocks (around line 5-6 area, after jest.mock calls).


11-27: applyPatch mock only handles single-level paths — acceptable for current tests but fragile.

op.path.replace( '/', '' ) strips only the first /, so a nested path like /settings/type would produce a flat key settings/type rather than a nested property update. All current tests use single-level paths so this works, but consider adding a comment noting this limitation to prevent confusion if deeper paths are added later.

packages/core-data/src/popups/__tests__/reducer.test.ts (1)

112-120: Test dispatches RECEIVE_RECORDS but is nested under the RECEIVE_QUERY_RECORDS describe block.

Line 115 dispatches ACTION_TYPES.RECEIVE_RECORDS rather than ACTION_TYPES.RECEIVE_QUERY_RECORDS. While the test assertion itself is valid (verifying that RECEIVE_RECORDS without a query doesn't update queries), it's misleadingly placed inside the RECEIVE_QUERY_RECORDS describe block. Consider moving it to the RECEIVE_RECORDS describe block above or changing the dispatch to RECEIVE_QUERY_RECORDS with no query in the payload.

packages/core-data/src/popups/__tests__/selectors.test.ts (1)

185-188: Redundant as unknown as number cast — 0 is already a number.

The cast was likely copied from the 'new' as unknown as number pattern on line 177, but 0 is natively a number and needs no coercion.

♻️ Suggested fix
-		const state = stateWith( { editorId: 0 as unknown as number } );
+		const state = stateWith( { editorId: 0 } );

Comment on lines +13 to +20
| Area | Source Files | Lines of Code | Test Files | Test Coverage |
|------|-------------|---------------|------------|---------------|
| PHP classes/ | 197 | ~15,000+ | 6 | ~3% |
| PHP includes/ | 85 | ~4,500+ | (above) | - |
| TS/TSX packages/ | 288 | ~12,000+ | 7 | ~2.4% |
| Legacy JS assets/ | 79 | ~5,000+ | 2 | ~2.5% |
| E2E specs | - | - | 3 | ~5% of workflows |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add blank lines around tables to satisfy markdownlint (MD058).

Multiple tables are missing blank lines before/after, which trips MD058. Please add a blank line above and below each table block.

Suggested fix
 **Current state**: ~649 source files, 16 test files. Roughly **2.5% test file coverage**.

 | Area | Source Files | Lines of Code | Test Files | Test Coverage |
 |------|-------------|---------------|------------|---------------|
 | PHP classes/ | 197 | ~15,000+ | 6 | ~3% |
 | PHP includes/ | 85 | ~4,500+ | (above) | - |
 | TS/TSX packages/ | 288 | ~12,000+ | 7 | ~2.4% |
 | Legacy JS assets/ | 79 | ~5,000+ | 2 | ~2.5% |
 | E2E specs | - | - | 3 | ~5% of workflows |
+

Also applies to: 27-36, 37-47, 48-53

🤖 Prompt for AI Agents
In `@claudedocs/testing-gap-analysis.md` around lines 13 - 20, The Markdown tables
in claudedocs/testing-gap-analysis.md are missing blank lines before and after
each table (MD058); edit the file to insert a single blank line immediately
above and immediately below every table block (including the table shown and
those at ranges 27-36, 37-47, 48-53) so each table is separated by blank lines
from surrounding text or other blocks, then run markdownlint to confirm MD058 is
resolved.

- PHP: 334 tests (312 passing, 22 pre-existing failures) across 16 test files
- JS/TS: 400 tests (24/24 suites passing) across 20 test files
- Fix Jest moduleNameMapper for @popup-maker/* monorepo packages
- Expand ConditionCallbacks from 14 to 58 tests covering all 8 methods
- Add coverage infrastructure (pcov, phpunit.xml whitelist)
- Document 7 bugs found by tests in claudedocs/BUGS-FOUND-BY-TESTS.md
- Fix 7 PHP test failures (analytics, options, logging, REST, models)
Add 9 new test files and expand 8 existing ones covering:
- Utils: Sanitize, Format, Fields, Options (pure function tests)
- Registries: Conditions, Cookies, Triggers
- Admin: Settings, Popups (sanitization pipelines)
- Expanded: Analytics, Logging, Subscribers, Array, REST Connect

Total suite: 866 PHP tests (1432 assertions), 400 JS/TS tests.
48 tests skipped (dist not built, pro-only features, known bugs).
Reverts 4 source code fixes that were bundled with test commits.
Tests now document and assert the actual (buggy) behavior with
BUG: prefixed test names and comments referencing BUGS-FOUND-BY-TESTS.md.

Source reverts:
- omit.ts: restore pick-instead-of-omit implementation (#3)
- reducer.ts: restore string/number PURGE key mismatch (#1)
- reducer.ts: restore RECEIVE_ERROR state mutation (#2)
- validation.ts: restore empty title bypass (#4)

Also updates bug documentation with 5 new bugs from Batch 2.
- Fix  cleanup in Webhook test tearDown
- Replace as any with as unknown as Type across TS test files
- Fix tautological INVALIDATE_RESOLUTION tests (proper per-ID state)
- Modernize phpunit.xml (remove deprecated attrs, filter -> coverage)
- Extract taxonomy registration to setUp/tearDown in ConditionCallbacks
- DRY REST_Connect_Test with setUp fixtures and helper method
- Fix analytics test using wrong option key (pum_settings -> PUM_Utils_Options API)
- Strengthen weak assertions (assertIsBool -> assertFalse, assertTrue(true) -> assertIsString)
- Use $id in assertion messages for better diagnostics
- Remove unused variables and dead code
- Fix toBeTruthy/toBeFalsy -> toBe(true)/toBe(false) for boolean returns
- Consolidate Bug #12 into Bug #6, add code block language specifiers
@danieliser
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (3)
tests/php/tests/PUM_DB_Subscribers_Test.php (2)

390-395: ⚠️ Potential issue | 🟡 Minor

The method names contradict the assertions.

Both methods say returns_false but assert assertTrue(). Rename them or flip the assertions so the names describe the behavior under test.

Also applies to: 472-477

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_DB_Subscribers_Test.php` around lines 390 - 395, The test
method test_update_negative_id_returns_false is misnamed: it asserts
assertTrue($result) while the name says "returns_false"; fix by making the name
reflect the actual expectation (e.g., rename to
test_update_negative_id_returns_true) or change the assertion to
assertFalse($result) and adjust the comment accordingly; apply the same
correction to the other conflicting test noted in the review (the test at
472-477) so method names and their assertions are consistent (check references
to $this->db->update and assertTrue/assertFalse when making the change).

630-636: ⚠️ Potential issue | 🟡 Minor

Use $id1 or remove it.

Right now the numeric-search test only proves that something matched. Use $id1 in the assertion to prove the expected row was returned, or drop the variable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_DB_Subscribers_Test.php` around lines 630 - 636, The test
assigns $id1 from $this->db->insert but never uses it; either remove $id1 or
assert that the numeric search returned that specific row. Update the assertion
after $results = $this->db->query([ 's' => '42' ]) to verify the returned rows
include the inserted ID (e.g. extract the ID/primary key from $results and
assert in_array($id1, ...)) so the test proves the expected row from db->insert
was matched by db->query; alternatively, delete the unused $id1 assignment if
you don't intend to assert it.
tests/php/tests/PUM_ConditionCallbacks_Test.php (1)

356-373: ⚠️ Potential issue | 🟡 Minor

Unregister pum_test_cpt after this test.

register_post_type() mutates global state, and this custom type is never removed. That can leak into later condition tests through $wp_post_types.

Suggested change
 		$result = PUM_ConditionCallbacks::post_type( $condition );
 
 		$this->assertTrue( $result, 'post_index should match on a post type archive page.' );
+
+		unregister_post_type( 'pum_test_cpt' );
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_ConditionCallbacks_Test.php` around lines 356 - 373, The
test registers a custom post type pum_test_cpt but never unregisters it, leaking
into global $wp_post_types; after creating the post and asserting
PUM_ConditionCallbacks::post_type results, call unregister_post_type for
'pum_test_cpt' (or move registration into setUp and unregister in tearDown) to
remove the custom type and restore global state so later tests aren't affected.
🧹 Nitpick comments (3)
packages/core-data/src/popups/__tests__/reducer.test.ts (1)

153-170: Add forward-looking expectations alongside BUG characterization tests

These BUG: tests correctly document current behavior, but they currently pass only when behavior is broken. Consider adding paired it.todo/it.skip tests asserting the expected fixed behavior so remediation intent is tracked in-suite.

Also applies to: 196-214, 260-284

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-data/src/popups/__tests__/reducer.test.ts` around lines 153 -
170, Add explicit future-facing assertions next to each current "BUG:" test so
the suite documents the intended fixed behavior: for the test that uses reducer
and ACTION_TYPES.RECEIVE_ERROR (and the other BUG blocks at lines 196-214 and
260-284), add a paired it.todo or it.skip that asserts the original state's
errors object is NOT mutated (e.g., errorRef.global remains null) and that the
reducer returns a new state/errors object with the updated value; reference the
existing reducer function and ACTION_TYPES.RECEIVE_ERROR to locate the code
under test and ensure the new test verifies immutability (no direct mutation of
baseState.errors) and correct returned payload handling.
tests/php/tests/PUM_Cookies_Test.php (1)

314-322: Minor: Unused loop variable $id.

The $id variable is declared but not used in the loop body. Consider using it in the assertion message for better debugging, or suppress with underscore prefix.

Suggested improvement
 	public function test_dropdown_list_structure() {
 		$list = $this->cookies->dropdown_list();
 		$this->assertIsArray( $list );
 		$this->assertNotEmpty( $list );

 		foreach ( $list as $id => $name ) {
-			$this->assertIsString( $name, 'Cookie name should be a string.' );
+			$this->assertIsString( $name, "Cookie '$id' name should be a string." );
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_Cookies_Test.php` around lines 314 - 322, In
test_dropdown_list_structure, the foreach declares $id but never uses it; either
include $id in the assertion message (e.g., reference the current key to make
failures clearer) or rename it to an intentionally unused variable like $_id to
indicate it's unused; update the loop over $this->cookies->dropdown_list() in
test_dropdown_list_structure to use one of these approaches so the
unused-variable warning is resolved and assertion messages are more informative.
tests/php/tests/FormConversionTracking_Test.php (1)

158-176: Action callback should accept both arguments.

The popup_maker/form_conversion_tracked action fires with two arguments ($popup_id, $event_data) per the production code (context snippet shows line 165-173), but this test callback only captures $popup_id. While the test passes, it doesn't verify the full action signature.

Suggested improvement
 	public function test_track_form_conversion_fires_action() {
 		$fired_popup_id = null;
+		$fired_args     = null;

 		add_action(
 			'popup_maker/form_conversion_tracked',
-			function ( $popup_id ) use ( &$fired_popup_id ) {
+			function ( $popup_id, $args ) use ( &$fired_popup_id, &$fired_args ) {
 				$fired_popup_id = $popup_id;
-			}
+				$fired_args     = $args;
+			},
+			10,
+			2
 		);

 		$this->service->track_form_conversion(
 			[
 				'popup_id'      => $this->popup_id,
 				'form_provider' => 'wpforms',
 			]
 		);

 		$this->assertEquals( $this->popup_id, $fired_popup_id, 'Action should fire with correct popup ID.' );
+		$this->assertIsArray( $fired_args, 'Action should include form args.' );
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/FormConversionTracking_Test.php` around lines 158 - 176, The
test test_track_form_conversion_fires_action subscribes to the
'popup_maker/form_conversion_tracked' action but its callback only accepts one
parameter, while the production code fires two ($popup_id, $event_data); update
the test's add_action callback in test_track_form_conversion_fires_action to
accept both arguments (e.g., function($popup_id, $event_data) use
(&$fired_popup_id, &$fired_event_data)) and pass the correct accepted args count
to add_action (priority and 2 accepted args), then assert that $fired_popup_id
equals $this->popup_id and that $fired_event_data contains expected keys/values
(e.g., 'form_provider' => 'wpforms') to fully verify the action signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claudedocs/testing-gap-analysis.md`:
- Around line 64-72: The Markdown tables (e.g., the one starting with "| # |
File | Lines | What Needs Testing | Risk | Testability |" and all other
pipe-table blocks at the listed ranges) are missing blank lines before and after
them which violates MD058; for each table block (including ranges 75-82, 85-93,
105-114, 117-123, 126-133, 145-156, 159-165, 174-187, 252-259, 264-273, 291-296
and any remaining tables) add a single blank line immediately before the table
start and a single blank line immediately after the table end so every table is
separated from surrounding content.

In `@packages/core-data/src/popups/__tests__/reducer.test.ts`:
- Around line 112-117: The test "does not update queries when no query provided"
is dispatching ACTION_TYPES.RECEIVE_RECORDS instead of
ACTION_TYPES.RECEIVE_QUERY_RECORDS, so it never exercises the reducer branch for
RECEIVE_QUERY_RECORDS; update the test to dispatch reducer( initialState, {
type: ACTION_TYPES.RECEIVE_QUERY_RECORDS, payload: { records } } as unknown as
ReducerAction ) (keeping the same records and assertions) so the reducer,
initialState, and the RECEIVE_QUERY_RECORDS path are actually tested.

In `@tests/php/tests/PUM_AnalyticsTEST.php`:
- Around line 32-35: Replace the skip with a hard test failure/assertion:
instead of calling markTestSkipped when pum_is_popup($popup) returns false,
assert that pum_is_popup($popup) is true (or call $this->fail with a descriptive
message) so the test fails when popup initialization regresses; update the
pum_is_popup check in PUM_AnalyticsTEST (remove markTestSkipped and use
$this->assertTrue(pum_is_popup($popup), 'pum_is_popup() returned false — plugin
failed to initialize a published popup.')).

In `@tests/php/tests/PUM_DB_Subscribers_Test.php`:
- Around line 23-26: Tests leave rows in the pum_subscribers table which makes
tests order-dependent; in PUM_DB_Subscribers_Test::setUp() clear the table
before each test using $wpdb (delete all rows from
{$wpdb->prefix}pum_subscribers) and remove any related version/options via
delete_option for the plugin's DB/version option(s), and add a tearDown() method
that repeats the same cleanup (clear pum_subscribers and delete related
version/options) to ensure a clean slate between tests; reference the test class
PUM_DB_Subscribers_Test, its setUp() method and add tearDown() so the cleanup
always runs.

In `@tests/php/tests/PUM_Services_Options_Test.php`:
- Around line 335-355: The test currently documents and asserts the buggy
behavior of update_many; change it to assert the intended contract (empty values
are removed): update the docblock to state that empty values should be removed
(remove the note about the source bug), keep or rename the test method to
something like test_update_many_removes_empty_values, call
$this->options->update_many([ 'willRemove' => '' ]), then assert the key is NOT
present (use $this->assertArrayNotHasKey('willRemove', $stored) or
assertFalse(isset($stored['willRemove']))) and still assert 'stays' remains
unchanged; reference the test method name test_update_many_removes_empty_values
and the update_many method to locate and apply the change.

In `@tests/php/tests/REST_Connect_Test.php`:
- Around line 414-425: The test test_install_route_is_post_only currently only
asserts POST is present; change it to assert the route exposes exactly one
custom method and that it is 'POST' by replacing the assertContains with an
exact equality check (e.g. assertEquals( ['POST'], array_keys(
$install_route[0]['methods'] ) )) so array_keys returns exactly ['POST']; locate
this in test_install_route_is_post_only and make the same change to the
corresponding other test mentioned (the similar test around lines 430-440) to
ensure they assert exact equality rather than membership.

In `@tests/php/tests/REST_ObjectSearch_Test.php`:
- Around line 95-99: The test currently registers a global rest_api_init
callback via add_action( 'rest_api_init', function () use ( $controller ) {
$controller->register_routes(); } ) which leaves a lingering closure and can
cause routes to be re-registered in later tests; instead, call the
controller->register_routes() directly (or if deferred behavior is required, add
and then remove the specific action) so no persistent global hook is left
behind—update the code around the controller and register_routes usage to remove
the temporary rest_api_init closure.

---

Duplicate comments:
In `@tests/php/tests/PUM_ConditionCallbacks_Test.php`:
- Around line 356-373: The test registers a custom post type pum_test_cpt but
never unregisters it, leaking into global $wp_post_types; after creating the
post and asserting PUM_ConditionCallbacks::post_type results, call
unregister_post_type for 'pum_test_cpt' (or move registration into setUp and
unregister in tearDown) to remove the custom type and restore global state so
later tests aren't affected.

In `@tests/php/tests/PUM_DB_Subscribers_Test.php`:
- Around line 390-395: The test method test_update_negative_id_returns_false is
misnamed: it asserts assertTrue($result) while the name says "returns_false";
fix by making the name reflect the actual expectation (e.g., rename to
test_update_negative_id_returns_true) or change the assertion to
assertFalse($result) and adjust the comment accordingly; apply the same
correction to the other conflicting test noted in the review (the test at
472-477) so method names and their assertions are consistent (check references
to $this->db->update and assertTrue/assertFalse when making the change).
- Around line 630-636: The test assigns $id1 from $this->db->insert but never
uses it; either remove $id1 or assert that the numeric search returned that
specific row. Update the assertion after $results = $this->db->query([ 's' =>
'42' ]) to verify the returned rows include the inserted ID (e.g. extract the
ID/primary key from $results and assert in_array($id1, ...)) so the test proves
the expected row from db->insert was matched by db->query; alternatively, delete
the unused $id1 assignment if you don't intend to assert it.

---

Nitpick comments:
In `@packages/core-data/src/popups/__tests__/reducer.test.ts`:
- Around line 153-170: Add explicit future-facing assertions next to each
current "BUG:" test so the suite documents the intended fixed behavior: for the
test that uses reducer and ACTION_TYPES.RECEIVE_ERROR (and the other BUG blocks
at lines 196-214 and 260-284), add a paired it.todo or it.skip that asserts the
original state's errors object is NOT mutated (e.g., errorRef.global remains
null) and that the reducer returns a new state/errors object with the updated
value; reference the existing reducer function and ACTION_TYPES.RECEIVE_ERROR to
locate the code under test and ensure the new test verifies immutability (no
direct mutation of baseState.errors) and correct returned payload handling.

In `@tests/php/tests/FormConversionTracking_Test.php`:
- Around line 158-176: The test test_track_form_conversion_fires_action
subscribes to the 'popup_maker/form_conversion_tracked' action but its callback
only accepts one parameter, while the production code fires two ($popup_id,
$event_data); update the test's add_action callback in
test_track_form_conversion_fires_action to accept both arguments (e.g.,
function($popup_id, $event_data) use (&$fired_popup_id, &$fired_event_data)) and
pass the correct accepted args count to add_action (priority and 2 accepted
args), then assert that $fired_popup_id equals $this->popup_id and that
$fired_event_data contains expected keys/values (e.g., 'form_provider' =>
'wpforms') to fully verify the action signature.

In `@tests/php/tests/PUM_Cookies_Test.php`:
- Around line 314-322: In test_dropdown_list_structure, the foreach declares $id
but never uses it; either include $id in the assertion message (e.g., reference
the current key to make failures clearer) or rename it to an intentionally
unused variable like $_id to indicate it's unused; update the loop over
$this->cookies->dropdown_list() in test_dropdown_list_structure to use one of
these approaches so the unused-variable warning is resolved and assertion
messages are more informative.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8c23cbc5-634a-4f5f-a0a7-904851b754e0

📥 Commits

Reviewing files that changed from the base of the PR and between 4a64918 and 5409608.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (51)
  • claudedocs/BUGS-FOUND-BY-TESTS.md
  • claudedocs/testing-gap-analysis.md
  • composer.json
  • packages/components/src/lib/url-control/__tests__/is-url-like.test.ts
  • packages/core-data/src/call-to-actions/__tests__/reducer.test.ts
  • packages/core-data/src/call-to-actions/__tests__/selectors.test.ts
  • packages/core-data/src/call-to-actions/__tests__/validation.test.ts
  • packages/core-data/src/license/__tests__/reducer.test.ts
  • packages/core-data/src/license/__tests__/selectors.test.ts
  • packages/core-data/src/popups/__tests__/reducer.test.ts
  • packages/core-data/src/popups/__tests__/selectors.test.ts
  • packages/core-data/src/popups/__tests__/validation.test.ts
  • packages/core-data/src/settings/__tests__/reducer.test.ts
  • packages/core-data/src/settings/__tests__/selectors.test.ts
  • packages/registry/src/__tests__/index.test.ts
  • packages/utils/src/lib/__tests__/clamp.test.ts
  • packages/utils/src/lib/__tests__/debug.test.ts
  • packages/utils/src/lib/__tests__/noop.test.ts
  • packages/utils/src/lib/__tests__/omit.test.ts
  • packages/utils/src/lib/__tests__/pick.test.ts
  • tests/php/config/bootstrap.php
  • tests/php/phpunit.xml
  • tests/php/tests/FormConversionTracking_Test.php
  • tests/php/tests/LinkClickTracking_Test.php
  • tests/php/tests/Logging_Service_Test.php
  • tests/php/tests/PUM_Admin_OnboardingTEST.php
  • tests/php/tests/PUM_Admin_Popups_Test.php
  • tests/php/tests/PUM_Admin_Settings_Test.php
  • tests/php/tests/PUM_AnalyticsTEST.php
  • tests/php/tests/PUM_Analytics_Expanded_Test.php
  • tests/php/tests/PUM_ConditionCallbacks_Test.php
  • tests/php/tests/PUM_Condition_Helpers_Test.php
  • tests/php/tests/PUM_Conditions_Test.php
  • tests/php/tests/PUM_Cookies_Test.php
  • tests/php/tests/PUM_DB_Subscribers_Test.php
  • tests/php/tests/PUM_Models_CallToAction_Test.php
  • tests/php/tests/PUM_Services_Options_Test.php
  • tests/php/tests/PUM_Triggers_Test.php
  • tests/php/tests/PUM_Utils_ArrayTest.php
  • tests/php/tests/PUM_Utils_Fields_Test.php
  • tests/php/tests/PUM_Utils_Format_Test.php
  • tests/php/tests/PUM_Utils_Options_Test.php
  • tests/php/tests/PUM_Utils_Sanitize_Test.php
  • tests/php/tests/PUM_Utils_Test.php
  • tests/php/tests/PopupMakerTEST.php
  • tests/php/tests/REST_Connect_Test.php
  • tests/php/tests/REST_ObjectSearch_Test.php
  • tests/php/tests/RestAPI/Test_License_REST_Endpoints.php
  • tests/php/tests/RestAPI/Test_Webhook_REST_Endpoints.php
  • tests/php/tests/test-pum_utils_array.php
  • tests/unit/jest.config.js
💤 Files with no reviewable changes (1)
  • tests/php/tests/test-pum_utils_array.php
✅ Files skipped from review due to trivial changes (15)
  • tests/unit/jest.config.js
  • tests/php/tests/RestAPI/Test_License_REST_Endpoints.php
  • packages/utils/src/lib/tests/noop.test.ts
  • tests/php/tests/RestAPI/Test_Webhook_REST_Endpoints.php
  • packages/core-data/src/call-to-actions/tests/validation.test.ts
  • packages/utils/src/lib/tests/pick.test.ts
  • packages/utils/src/lib/tests/clamp.test.ts
  • packages/core-data/src/popups/tests/validation.test.ts
  • packages/core-data/src/license/tests/reducer.test.ts
  • claudedocs/BUGS-FOUND-BY-TESTS.md
  • tests/php/tests/PUM_Condition_Helpers_Test.php
  • packages/core-data/src/license/tests/selectors.test.ts
  • packages/core-data/src/settings/tests/reducer.test.ts
  • tests/php/tests/PUM_Utils_Test.php
  • packages/core-data/src/call-to-actions/tests/reducer.test.ts
🚧 Files skipped from review as they are similar to previous changes (14)
  • composer.json
  • packages/utils/src/lib/tests/omit.test.ts
  • tests/php/phpunit.xml
  • packages/registry/src/tests/index.test.ts
  • packages/core-data/src/settings/tests/selectors.test.ts
  • tests/php/tests/PUM_Conditions_Test.php
  • tests/php/tests/PUM_Utils_Sanitize_Test.php
  • packages/utils/src/lib/tests/debug.test.ts
  • packages/components/src/lib/url-control/tests/is-url-like.test.ts
  • tests/php/tests/PUM_Models_CallToAction_Test.php
  • tests/php/tests/PUM_Analytics_Expanded_Test.php
  • packages/core-data/src/popups/tests/selectors.test.ts
  • tests/php/tests/PUM_Utils_ArrayTest.php
  • tests/php/tests/Logging_Service_Test.php

Comment on lines +64 to +72
| # | File | Lines | What Needs Testing | Risk | Testability |
|---|------|-------|-------------------|------|-------------|
| 1 | `Services/Connect.php` | 635 | Remote server connection, token validation, webhook auth | 🔴 Critical | Moderate |
| 2 | `RestAPI/Connect.php` | 584 | Plugin install/upgrade webhooks, auth verification | 🔴 Critical | Moderate |
| 3 | `DB/Subscribers.php` | 262 | SQL queries, subscriber data CRUD, table creation | 🔴 Critical | Easy |
| 4 | `Services/License.php` | 1,135 | License validation, activation/deactivation, status checks | 🔴 Critical | Moderate |
| 5 | `RestAPI/License.php` | 629 | License REST endpoints (partially tested, expand) | 🟡 High | Easy |
| 6 | `Analytics.php` | 354 | Event tracking, data recording (partially tested, expand) | 🟡 High | Easy |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add blank lines around all remaining tables to satisfy MD058.

Multiple additional tables throughout the document are missing blank lines before and after. Please add a blank line immediately before and after each table block.

Example fix pattern for all tables
 **Bottom line**: The codebase has massive testing gaps everywhere. This plan prioritizes by **risk x effort** — what catches the most bugs per test dollar spent.
 
 ---
 
 ## What IS Currently Tested
+
 | Test File | What It Covers | Quality |
 |-----------|---------------|---------|
 | `test-popup-maker.php` | Plugin instantiation, constants defined | Smoke test only |
 ...
 | `test-webhook-endpoints.php` | Webhook verify security, install validation, endpoint registration | Good security coverage |
+
 ### JS/TS (7 test files)

Apply this pattern (add blank line before and after) to all tables in the document.

Also applies to: 75-82, 85-93, 105-114, 117-123, 126-133, 145-156, 159-165, 174-187, 252-259, 264-273, 291-296

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claudedocs/testing-gap-analysis.md` around lines 64 - 72, The Markdown tables
(e.g., the one starting with "| # | File | Lines | What Needs Testing | Risk |
Testability |" and all other pipe-table blocks at the listed ranges) are missing
blank lines before and after them which violates MD058; for each table block
(including ranges 75-82, 85-93, 105-114, 117-123, 126-133, 145-156, 159-165,
174-187, 252-259, 264-273, 291-296 and any remaining tables) add a single blank
line immediately before the table start and a single blank line immediately
after the table end so every table is separated from surrounding content.

Comment on lines +112 to +117
it( 'does not update queries when no query provided', () => {
const records = [ mockPopup( 5 ) ];
const state = reducer( initialState, {
type: ACTION_TYPES.RECEIVE_RECORDS,
payload: { records },
} as unknown as ReducerAction );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

RECEIVE_QUERY_RECORDS “no query” case is not exercising that reducer branch

On Line 115, the test dispatches ACTION_TYPES.RECEIVE_RECORDS, so it never validates the RECEIVE_QUERY_RECORDS path without a query.

Suggested patch
 		it( 'does not update queries when no query provided', () => {
 			const records = [ mockPopup( 5 ) ];
 			const state = reducer( initialState, {
-				type: ACTION_TYPES.RECEIVE_RECORDS,
+				type: ACTION_TYPES.RECEIVE_QUERY_RECORDS,
 				payload: { records },
 			} as unknown as ReducerAction );

 			expect( state.queries ).toEqual( initialState.queries );
 		} );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it( 'does not update queries when no query provided', () => {
const records = [ mockPopup( 5 ) ];
const state = reducer( initialState, {
type: ACTION_TYPES.RECEIVE_RECORDS,
payload: { records },
} as unknown as ReducerAction );
it( 'does not update queries when no query provided', () => {
const records = [ mockPopup( 5 ) ];
const state = reducer( initialState, {
type: ACTION_TYPES.RECEIVE_QUERY_RECORDS,
payload: { records },
} as unknown as ReducerAction );
expect( state.queries ).toEqual( initialState.queries );
} );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-data/src/popups/__tests__/reducer.test.ts` around lines 112 -
117, The test "does not update queries when no query provided" is dispatching
ACTION_TYPES.RECEIVE_RECORDS instead of ACTION_TYPES.RECEIVE_QUERY_RECORDS, so
it never exercises the reducer branch for RECEIVE_QUERY_RECORDS; update the test
to dispatch reducer( initialState, { type: ACTION_TYPES.RECEIVE_QUERY_RECORDS,
payload: { records } } as unknown as ReducerAction ) (keeping the same records
and assertions) so the reducer, initialState, and the RECEIVE_QUERY_RECORDS path
are actually tested.

Comment on lines +32 to +35
// Verify pum_is_popup succeeds, otherwise tracking will be skipped.
if ( ! pum_is_popup( $popup ) ) {
$this->markTestSkipped( 'pum_is_popup() returned false — plugin may not be fully initialized in test context.' );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail here instead of skipping.

pum_is_popup() returning false after creating a published popup is exactly the regression this test should catch. markTestSkipped() turns that failure into a green build and hides broken popup initialization.

Suggested change
-		// Verify pum_is_popup succeeds, otherwise tracking will be skipped.
-		if ( ! pum_is_popup( $popup ) ) {
-			$this->markTestSkipped( 'pum_is_popup() returned false — plugin may not be fully initialized in test context.' );
-		}
+		$this->assertTrue(
+			pum_is_popup( $popup ),
+			'Expected pum_get_popup() to return a valid popup model.'
+		);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_AnalyticsTEST.php` around lines 32 - 35, Replace the skip
with a hard test failure/assertion: instead of calling markTestSkipped when
pum_is_popup($popup) returns false, assert that pum_is_popup($popup) is true (or
call $this->fail with a descriptive message) so the test fails when popup
initialization regresses; update the pum_is_popup check in PUM_AnalyticsTEST
(remove markTestSkipped and use $this->assertTrue(pum_is_popup($popup),
'pum_is_popup() returned false — plugin failed to initialize a published
popup.')).

Comment on lines +23 to +26
public function setUp(): void {
parent::setUp();
$this->db = new PUM_DB_Subscribers();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset the subscribers table between tests.

This suite writes directly to pum_subscribers, but WP_UnitTestCase will not clean custom tables for you. Exact-count assertions and even the negative-ID cases become order-dependent because earlier rows remain in place. Add setup/teardown cleanup for the table and related version options.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_DB_Subscribers_Test.php` around lines 23 - 26, Tests
leave rows in the pum_subscribers table which makes tests order-dependent; in
PUM_DB_Subscribers_Test::setUp() clear the table before each test using $wpdb
(delete all rows from {$wpdb->prefix}pum_subscribers) and remove any related
version/options via delete_option for the plugin's DB/version option(s), and add
a tearDown() method that repeats the same cleanup (clear pum_subscribers and
delete related version/options) to ensure a clean slate between tests; reference
the test class PUM_DB_Subscribers_Test, its setUp() method and add tearDown() so
the cleanup always runs.

Comment on lines +335 to +355
/**
* Test update_many with empty values.
*
* NOTE: Source has a bug — update_many() unsets the key on line 173 when
* value is empty, but then unconditionally re-sets it on line 187 with
* the empty value. So the key persists as empty string. This test asserts
* actual (buggy) behavior.
*/
public function test_update_many_removes_empty_values() {
update_option( $this->option_key, [ 'willRemove' => 'exists', 'stays' => 'here' ] );

$this->options->update_many( [
'willRemove' => '',
] );

$stored = get_option( $this->option_key );
// BUG: key is NOT removed because line 187 re-sets it after unset.
$this->assertArrayHasKey( 'willRemove', $stored );
$this->assertSame( '', $stored['willRemove'] );
$this->assertSame( 'here', $stored['stays'] );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't make the bug look like the contract.

The docblock and method name say empty values are removed, but the assertions deliberately require the current bug (willRemove still exists). That makes the eventual fix look like a regression and obscures that this is a known-bug characterization.

Suggested change
-	/**
-	 * Test update_many with empty values.
-	 *
-	 * NOTE: Source has a bug — update_many() unsets the key on line 173 when
-	 * value is empty, but then unconditionally re-sets it on line 187 with
-	 * the empty value. So the key persists as empty string. This test asserts
-	 * actual (buggy) behavior.
-	 */
-	public function test_update_many_removes_empty_values() {
+	/**
+	 * BUG: update_many() currently keeps empty-string values instead of removing them.
+	 */
+	public function test_bug_update_many_keeps_empty_values() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/PUM_Services_Options_Test.php` around lines 335 - 355, The
test currently documents and asserts the buggy behavior of update_many; change
it to assert the intended contract (empty values are removed): update the
docblock to state that empty values should be removed (remove the note about the
source bug), keep or rename the test method to something like
test_update_many_removes_empty_values, call $this->options->update_many([
'willRemove' => '' ]), then assert the key is NOT present (use
$this->assertArrayNotHasKey('willRemove', $stored) or
assertFalse(isset($stored['willRemove']))) and still assert 'stays' remains
unchanged; reference the test method name test_update_many_removes_empty_values
and the update_many method to locate and apply the change.

Comment on lines +414 to +425
public function test_install_route_is_post_only() {
[ $controller ] = $this->create_controller_with_mock_service(
[ 'debug_log', 'get_access_token', 'get_request_token', 'generate_hash', 'debug_mode_enabled' ]
);

$controller->register_routes();
$routes = rest_get_server()->get_routes();

$install_route = $routes['/popup-maker/v2/connect/install'];
// The first element should have 'methods' containing POST.
$this->assertContains( 'POST', array_keys( $install_route[0]['methods'] ), 'Install route should accept POST.' );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

These checks don't verify "POST only".

They only prove that POST is one allowed method. If GET or PUT gets added accidentally, both tests still pass. Assert that the route exposes exactly one custom method and that it's POST.

Suggested change
-		$this->assertContains( 'POST', array_keys( $install_route[0]['methods'] ), 'Install route should accept POST.' );
+		$this->assertCount( 1, $install_route[0]['methods'] );
+		$this->assertArrayHasKey( 'POST', $install_route[0]['methods'] );
...
-		$this->assertContains( 'POST', array_keys( $verify_route[0]['methods'] ), 'Verify route should accept POST.' );
+		$this->assertCount( 1, $verify_route[0]['methods'] );
+		$this->assertArrayHasKey( 'POST', $verify_route[0]['methods'] );

Also applies to: 430-440

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/REST_Connect_Test.php` around lines 414 - 425, The test
test_install_route_is_post_only currently only asserts POST is present; change
it to assert the route exposes exactly one custom method and that it is 'POST'
by replacing the assertContains with an exact equality check (e.g. assertEquals(
['POST'], array_keys( $install_route[0]['methods'] ) )) so array_keys returns
exactly ['POST']; locate this in test_install_route_is_post_only and make the
same change to the corresponding other test mentioned (the similar test around
lines 430-440) to ensure they assert exact equality rather than membership.

Comment on lines +95 to +99
// Register routes within rest_api_init to avoid WP "incorrect usage" notice.
$controller = $this->controller;
add_action( 'rest_api_init', function () use ( $controller ) {
$controller->register_routes();
} );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove the temporary rest_api_init callback.

This adds a global hook and never unregisters it. Later REST tests can re-fire rest_api_init and re-register the route through this leftover closure.

Suggested change
-		add_action( 'rest_api_init', function () use ( $controller ) {
+		$callback = function () use ( $controller ) {
 			$controller->register_routes();
-		} );
+		};
+		add_action( 'rest_api_init', $callback );
@@
 		$this->assertArrayHasKey(
 			'/popup-maker/v2/object-search',
 			$routes,
 			'Object search route should be registered.'
 		);
+
+		remove_action( 'rest_api_init', $callback );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Register routes within rest_api_init to avoid WP "incorrect usage" notice.
$controller = $this->controller;
add_action( 'rest_api_init', function () use ( $controller ) {
$controller->register_routes();
} );
// Register routes within rest_api_init to avoid WP "incorrect usage" notice.
$controller = $this->controller;
$callback = function () use ( $controller ) {
$controller->register_routes();
};
add_action( 'rest_api_init', $callback );
$this->assertArrayHasKey(
'/popup-maker/v2/object-search',
$routes,
'Object search route should be registered.'
);
remove_action( 'rest_api_init', $callback );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/php/tests/REST_ObjectSearch_Test.php` around lines 95 - 99, The test
currently registers a global rest_api_init callback via add_action(
'rest_api_init', function () use ( $controller ) {
$controller->register_routes(); } ) which leaves a lingering closure and can
cause routes to be re-registered in later tests; instead, call the
controller->register_routes() directly (or if deferred behavior is required, add
and then remove the specific action) so no persistent global hook is left
behind—update the code around the controller and register_routes usage to remove
the temporary rest_api_init closure.

@danieliser danieliser merged commit 83a0e2e into develop Mar 25, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant