Skip to content

fix(geoip): prevent infinite AJAX loop when WP-Cron disabled#166

Merged
parhumm merged 14 commits intodevelopmentfrom
fix/164-geoip-infinite-ajax-loop
Mar 11, 2026
Merged

fix(geoip): prevent infinite AJAX loop when WP-Cron disabled#166
parhumm merged 14 commits intodevelopmentfrom
fix/164-geoip-infinite-ajax-loop

Conversation

@parhumm
Copy link
Copy Markdown
Contributor

@parhumm parhumm commented Mar 10, 2026

Summary

Closes #164

This PR fixes the infinite GeoIP AJAX loop when WP-Cron is disabled, wires the Cloudflare geolocation provider into the tracking pipeline, and fixes several related geolocation bugs that affected fresh installs, legacy upgrades, and admin page stability.

What changed for users

Fresh installs

  • Geolocation now works out of the box — defaults to DB-IP provider (previously no provider was set, silently disabling geolocation)

Existing installs (upgrading)

  • Sites with legacy enable_maxmind setting are automatically migrated to the new geolocation_provider setting
  • enable_maxmind=on maps to maxmind, enable_maxmind=no maps to dbip, enable_maxmind=disable maps to disable
  • No action required from the user

Cloudflare users

  • Cloudflare geolocation provider now fully works end-to-end — country, city, subdivision, and coordinates are tracked when Cloudflare headers are present
  • Previously, selecting Cloudflare in settings had no effect on tracking

Sites with WP-Cron disabled

  • Fixed infinite AJAX loop that could generate hundreds of thousands of requests per minute
  • GeoIP update fallback now fires at most once, with proper guards against recursion

Admin stability

  • Settings pages no longer crash (fatal error) when a non-database provider like Cloudflare or Disabled is selected
  • "Update Database" and "Check Database" buttons gracefully handle non-DB providers

Bugs fixed

# Bug Impact Fix
A GeoIP AJAX fallback fires on every init when cron disabled Server overload, infinite loop Guard: is_admin() && !wp_doing_ajax() && current_user_can()
B AJAX handler never updates slimstat_last_geoip_dl timestamp Loop never stops update_option('slimstat_last_geoip_dl', time()) on success
C 12+ inconsistent provider defaults (?? 'maxmind' / ?? 'dbip') Wrong provider used in different code paths Centralized resolve_geolocation_provider()
D Fresh installs have no geolocation_provider default Geolocation silently disabled get_fresh_defaults() sets geolocation_provider=dbip
E isGeoIPEnabled() excluded Cloudflare Cloudflare provider never ran Removed Cloudflare exclusion
F Processor hardcoded 'maxmind' or 'dbip' Cloudflare/disable never passed to factory Uses resolve_geolocation_provider() directly
G checkDatabase()/deleteDatabaseFile() called getDbPath() on Cloudflare Fatal error on admin pages Guard with DB_PROVIDERS constant
H Cloudflare returns region key, Processor reads subdivision Region data lost in city precision mode region mapped to subdivision
I Cloudflare early return skips timestamp update Loop continues for Cloudflare users Timestamp set before wp_send_json_success
J Legacy enable_maxmind not migrated on upgrade Wrong provider in settings dropdown Lazy migration in resolve_geolocation_provider()

Code architecture changes

  • resolve_geolocation_provider() — single source of truth for active provider, handles legacy migration
  • get_fresh_defaults() — centralized install defaults, used in both init() and admin reset
  • get_geolocation_precision() — eliminates duplicated country/city precision logic
  • DB_PROVIDERS constant['maxmind', 'dbip'] used everywhere to guard DB-specific operations
  • Processor hot path — removed unnecessary GeoService object instantiation per pageview

Files changed (33 files, +2411 / -110)

Core plugin:

  • wp-slimstat.phpresolve_geolocation_provider(), get_fresh_defaults(), get_geolocation_precision(), init() fix
  • src/Tracker/Processor.php — Direct resolver usage, removed GeoService dependency
  • src/Services/GeoService.phpDB_PROVIDERS constant, guards, fixed isGeoIPEnabled()
  • src/Services/Geolocation/Provider/CloudflareGeolocationProvider.php — region to subdivision mapping

Admin:

  • admin/index.php — Provider-aware AJAX handlers, DB_PROVIDERS guards
  • admin/config/index.php — Lazy migration, save sync, reset uses get_fresh_defaults()
  • admin/view/index.php — Provider-aware GeoIP notice
  • admin/view/wp-slimstat-reports.php — Provider-aware GeoIP notice

Tests (new):

  • 5 PHP unit test files (67 assertions): provider resolution, GeoService, legacy sync, lazy migration, license tags
  • 8 E2E tests: geolocation provider pipeline (fresh install, 3 legacy upgrades, DB-IP, Cloudflare with header injection, disabled, admin pages)
  • 6 E2E tests: GeoIP AJAX loop prevention
  • k6 load test for AJAX loop verification
  • Test infrastructure: option-mutator mu-plugin, AJAX logger, Playwright config

Test results

All tests passing — see test results comment for details.

  • 67 PHP unit assertions across 5 test files
  • 8 E2E geolocation provider pipeline tests
  • 6 E2E AJAX loop regression tests

When DISABLE_WP_CRON is true, the GeoIP fallback fires on every init
hook including its own AJAX requests, and the AJAX handler never updates
slimstat_last_geoip_dl — creating an infinite self-replicating loop.

Root causes fixed:
- Guard fallback: admin-only, no AJAX recursion, capability-gated,
  provider-aware (skips disabled installs)
- AJAX handler: update timestamp on success and Cloudflare early return
- Provider resolution: centralized resolve_geolocation_provider() helper
  respects legacy enable_maxmind tri-state (on/no/disable)
- Config save: sync enable_maxmind when geolocation_provider is saved
- Settings UI: lazy-migrate legacy flag, add 'Disabled' select option
- Tracker: wire GeoService to resolver for authoritative provider state
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a centralized provider resolver and a "disable" geolocation provider; updates admin UI, gating for cron/AJAX and GeoIP update flows, GeoService behavior to short-circuit when disabled, lazy-migration/legacy sync, and extensive unit, e2e, perf, and QA test tooling.

Changes

Cohort / File(s) Summary
Provider Core
wp-slimstat.php
Add public static function resolve_geolocation_provider() to centralize provider resolution (honors geolocation_provider, falls back to legacy enable_maxmind, returns 'maxmind', 'dbip', 'cloudflare', or false).
Admin Config & UI
admin/config/index.php, admin/assets/js/admin.js
Add 'disable' option, lazy-migrate legacy enable_maxmind into geolocation_provider, sync legacy flag on save, and hide license & DB action rows for 'disable'.
Admin Logic & Views
admin/index.php, admin/view/index.php, admin/view/wp-slimstat-reports.php
Use resolver instead of direct settings access, gate AJAX fallback to admin+capability, short-circuit updates/checks when provider resolves to false, update timestamps and provider-specific messaging.
Service Layer
src/Services/GeoService.php
Refactor methods (isGeoIPEnabled, isMaxMindEnabled, download, checkDatabase, deleteDatabaseFile) to use resolver; return early when provider is false and preserve provider-specific flows for maxmind/dbip/cloudflare.
Tests — Unit & Integration
tests/*.php (tests/resolve-geolocation-provider-test.php, tests/geoservice-provider-resolution-test.php, tests/lazy-migration-test.php, tests/legacy-sync-mapping-test.php)
Add tests covering resolver behavior, lazy migration, legacy sync mapping, and GeoService provider resolution across disable/cloudflare/maxmind/dbip and legacy scenarios.
E2E, Perf & QA Tooling
tests/e2e/*, tests/perf/geoip-load.js, tests/run-qa.sh, tests/e2e/helpers/*, tests/e2e/playwright.config.ts
Add Playwright e2e tests, global setup, helpers (DB, cron, mu-plugin, AJAX log), k6 perf script, QA orchestration script, and MU-plugin to log AJAX calls.
Dev Scripts
composer.json, package.json
Add composer test scripts and npm test scripts plus devDependencies (@playwright/test, mysql2) to support e2e and perf testing.
Docs & Repo
docs/TESTING.md, .gitignore
Add comprehensive testing documentation and ignore rules for test artifacts.

Sequence Diagram(s)

sequenceDiagram
    participant AdminUI as Admin UI
    participant Resolver as wp_slimstat::resolve_geolocation_provider()
    participant GeoSvc as GeoService
    participant DB as Local GeoIP DB
    participant Ext as External Provider

    AdminUI->>Resolver: request provider
    Resolver-->>AdminUI: 'maxmind' / 'dbip' / 'cloudflare' / false

    alt provider == false
        AdminUI->>GeoSvc: request update/check
        GeoSvc->>Resolver: confirm provider
        Resolver-->>GeoSvc: false
        GeoSvc-->>AdminUI: return error/notice (geolocation disabled)
    else provider == 'cloudflare'
        AdminUI->>GeoSvc: request update
        GeoSvc->>Resolver: confirm provider
        Resolver-->>GeoSvc: 'cloudflare'
        GeoSvc-->>AdminUI: update last-run timestamp (no DB required)
    else provider == 'maxmind' or 'dbip'
        AdminUI->>GeoSvc: request update/check
        GeoSvc->>Resolver: confirm provider
        Resolver-->>GeoSvc: 'maxmind'/'dbip'
        GeoSvc->>Ext: download/check from external provider
        Ext-->>GeoSvc: data / error
        GeoSvc->>DB: write/delete DB file / update timestamps
        DB-->>GeoSvc: ack
        GeoSvc-->>AdminUI: success or provider-specific error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

🐰 I hopped through settings, tidy and spry,
A "disable" carrot winked in my eye.
Resolver sorted providers neat and true,
No more AJAX echoes — quieted the queue. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing an infinite AJAX loop when WP-Cron is disabled, which directly addresses issue #164.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/164-geoip-infinite-ajax-loop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…ing raw lookups

- Config save: skip DB check/schedule when provider is 'disable' (not
  just 'cloudflare'), preventing wasted GeolocationService instantiation
- admin/view/index.php and wp-slimstat-reports.php: replace raw
  geolocation_provider ?? 'dbip' with resolve_geolocation_provider()
  for correct legacy MaxMind provider resolution
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

🧹 Nitpick comments (2)
src/Services/GeoService.php (2)

76-79: Potential inconsistency: isJsDelivrEnabled() still uses legacy enable_maxmind property.

This method checks 'no' == $this->enableMaxmind but with the new provider system, enable_maxmind can now be 'disable'. When geolocation is disabled via the new UI (geolocation_provider = 'disable'), the sync logic sets enable_maxmind = 'disable', causing this method to return false (which is likely correct behavior).

However, for consistency with the rest of the PR's changes, consider updating this method to use the resolver:

♻️ Suggested refactor for consistency
 public function isJsDelivrEnabled()
 {
-    return 'no' == $this->enableMaxmind;
+    return 'dbip' === \wp_slimstat::resolve_geolocation_provider();
 }

This aligns with how isGeoIPEnabled() and isMaxMindEnabled() now work.

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

In `@src/Services/GeoService.php` around lines 76 - 79, The isJsDelivrEnabled()
method still checks the legacy enableMaxmind value; update it to use the new
provider resolver like the other methods (e.g., mirror the pattern used in
isGeoIPEnabled() and isMaxMindEnabled()) so it reads the geolocation/provider
state from the resolver instead of comparing $this->enableMaxmind to 'no' (or
alternatively check geolocation_provider !== 'disable' via the resolver); adjust
isJsDelivrEnabled() to call the resolver method that determines JsDelivr/mirror
availability.

17-21: Consider: Constructor reads enable_maxmind directly but key methods now bypass it.

The constructor still initializes $this->enableMaxmind from settings, but isGeoIPEnabled() and isMaxMindEnabled() now ignore this property and call the resolver directly. This creates a subtle inconsistency where:

  1. $this->enableMaxmind is set but mostly unused
  2. isJsDelivrEnabled() and getEnableMaxmind() still use the property
  3. The other methods use the resolver

This is not a bug—the current behavior is correct—but the property and its accessors (setEnableMaxmind, getEnableMaxmind) are now partially orphaned. This could be cleaned up in a follow-up to reduce confusion.

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

In `@src/Services/GeoService.php` around lines 17 - 21, The constructor sets
$this->enableMaxmind but several methods (isGeoIPEnabled, isMaxMindEnabled) now
call the resolver directly, leaving the property and its accessors partially
orphaned; to clean this up, remove the unused property and its setter/getter
(setEnableMaxmind, getEnableMaxmind) and update any methods that currently read
the property (isJsDelivrEnabled, getEnableMaxmind usages) to call the same
resolver used by isGeoIPEnabled()/isMaxMindEnabled() so all checks consistently
use the resolver; locate symbols __construct, $this->enableMaxmind,
isGeoIPEnabled(), isMaxMindEnabled(), isJsDelivrEnabled(), setEnableMaxmind(),
and getEnableMaxmind to make these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wp-slimstat.php`:
- Around line 359-372: The resolve_geolocation_provider() function currently
treats any set value for self::$settings['geolocation_provider'] (including
empty or malformed strings) as authoritative; update it to sanitize and
allowlist the new setting before returning it and only use the legacy
enable_maxmind flag if the new setting is missing or invalid. Specifically, in
resolve_geolocation_provider() call sanitize_text_field() on
self::$settings['geolocation_provider'], check it against an explicit allowed
set (e.g., 'disable', 'maxmind', 'dbip') and return false for 'disable' or the
allowed provider name; if the sanitized value is not in the allowlist, fall back
to reading self::$settings['enable_maxmind'] (handle 'on' -> 'maxmind', 'no' ->
'dbip') and return false otherwise.

---

Nitpick comments:
In `@src/Services/GeoService.php`:
- Around line 76-79: The isJsDelivrEnabled() method still checks the legacy
enableMaxmind value; update it to use the new provider resolver like the other
methods (e.g., mirror the pattern used in isGeoIPEnabled() and
isMaxMindEnabled()) so it reads the geolocation/provider state from the resolver
instead of comparing $this->enableMaxmind to 'no' (or alternatively check
geolocation_provider !== 'disable' via the resolver); adjust isJsDelivrEnabled()
to call the resolver method that determines JsDelivr/mirror availability.
- Around line 17-21: The constructor sets $this->enableMaxmind but several
methods (isGeoIPEnabled, isMaxMindEnabled) now call the resolver directly,
leaving the property and its accessors partially orphaned; to clean this up,
remove the unused property and its setter/getter (setEnableMaxmind,
getEnableMaxmind) and update any methods that currently read the property
(isJsDelivrEnabled, getEnableMaxmind usages) to call the same resolver used by
isGeoIPEnabled()/isMaxMindEnabled() so all checks consistently use the resolver;
locate symbols __construct, $this->enableMaxmind, isGeoIPEnabled(),
isMaxMindEnabled(), isJsDelivrEnabled(), setEnableMaxmind(), and
getEnableMaxmind to make these changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a7c5730a-6186-4a8f-bb8f-c1ac76784537

📥 Commits

Reviewing files that changed from the base of the PR and between e59f22e and 5c4d247.

📒 Files selected for processing (5)
  • admin/assets/js/admin.js
  • admin/config/index.php
  • admin/index.php
  • src/Services/GeoService.php
  • wp-slimstat.php

When the previous provider was disabled, resolve_geolocation_provider()
returns false and ?: 'dbip' coerced it to 'dbip', making the provider
change detection at line 877 inaccurate. Now maps false → 'disable' so
the comparison is correct for all state transitions.
@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 10, 2026

QA Checklist

Setup

For each scenario, use the database directly (wp_optionsslimstat_options) to set the starting state. Delete slimstat_last_geoip_dl from wp_options to reset the timestamp.


1. Infinite loop fix (critical path)

  • DISABLE_WP_CRON=true, enable_maxmind='no', no geolocation_provider — Load any wp-admin page. Open browser DevTools → Network tab. Confirm exactly ONE admin-ajax.php request with action=slimstat_update_geoip_database. Reload the page — no further AJAX requests. Check wp_optionsslimstat_last_geoip_dl is set.

2. Recursion guard

  • DISABLE_WP_CRON=true — The fallback AJAX request itself must NOT trigger another fallback. Verify by checking server access logs or adding a temporary error_log() inside the guard — it should fire once per admin page load, never from admin-ajax.php.

3. Non-admin / frontend isolation

  • Log in as a Subscriber or WooCommerce Customer → visit wp-admin → no GeoIP AJAX fires (check Network tab).
  • Log in as an Admin → visit the frontend (not wp-admin) → no GeoIP AJAX fires.

4. Provider resolution — legacy installs

  • enable_maxmind='on', no geolocation_provider → fallback/cron uses MaxMind provider (check downloaded file name or error if no license key).
  • enable_maxmind='no', no geolocation_provider → fallback/cron uses DB-IP provider.
  • enable_maxmind='disable', no geolocation_provider → no fallback fires, no cron download, tracker skips GeoIP lookup entirely.

5. Settings page — select rendering

  • Legacy DB-IP install (enable_maxmind='no', no geolocation_provider) → open Settings → Maintenance tab → "Geolocation Provider" dropdown pre-selects "DB-IP City Lite".
  • Legacy disabled (enable_maxmind='disable') → dropdown pre-selects "Disabled".
  • Legacy MaxMind (enable_maxmind='on') → dropdown pre-selects "MaxMind GeoLite2".
  • Selecting "Disabled" → license key row and DB action buttons hide immediately (JS toggle).

6. Settings save — legacy flag sync

  • Change provider to MaxMind and save → check slimstat_options in DB: enable_maxmind should be 'on'.
  • Change provider to DB-IP and save → enable_maxmind should be 'no'.
  • Change provider to Disabled and save → enable_maxmind should be 'disable'. No "background update scheduled" message shown.
  • Change provider to Cloudflare and save → enable_maxmind should be 'no'. No DB update scheduled.

7. AJAX handlers

  • Click "Update Database" button with DB-IP selected → success message, slimstat_last_geoip_dl updated in DB.
  • Click "Update Database" with Cloudflare selected → "does not require a database" success, timestamp updated.
  • Click "Check Database" with Disabled selected → "Geolocation is disabled" error.

8. Cron path (DISABLE_WP_CRON=false)

  • With cron enabled and a DB-based provider → fallback block never fires (verify $cron_disabled is false). Cron handler downloads correct provider DB.
  • With cron enabled and Disabled → cron handler returns immediately without downloading.

9. Tracker integration

  • Set geolocation_provider='dbip' + save → visit site as anonymous user → new pageview row has country populated from DB-IP lookup.
  • Set geolocation_provider='disable' + save → visit site → pageview row has empty country (GeoIP skipped).

10. Regression checks

  • Existing GeoIP data in reports is still displayed correctly.
  • World map report shows correct "DB missing" notice only when a DB provider is selected AND the file is actually missing.
  • No PHP errors/warnings in debug.log during any of the above.

parhumm added 3 commits March 10, 2026 15:40
The resolver now calls sanitize_text_field() and checks against an
explicit allowlist (maxmind, dbip, cloudflare, disable). Empty or
malformed values fall through to the legacy enable_maxmind flag instead
of being returned as-is, preventing invalid provider strings from
reaching GeolocationFactory.
Add 4 test suites (67 assertions) covering the GeoIP provider resolver,
GeoService wiring, legacy enable_maxmind sync mapping, and lazy migration.
Wire all tests into `composer test` alongside existing license-tag tests.
Composer 2.9 doesn't resolve 'composer test' as a shorthand for custom
scripts. Rename to 'test:all' matching the colon-namespaced convention.
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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@composer.json`:
- Around line 39-49: Add an integration test that reproduces regression `#164`:
create a new test file (e.g., tests/admin-ajax-geoip-regression-164-test.php)
that exercises the admin fallback → AJAX handler flow, the capability/AJAX
guards, and verifies slimstat_last_geoip_dl is updated (ensuring the
self-replicating loop is resolved); then add a composer script entry (e.g.,
"test:geoip-regression-164": "php
tests/admin-ajax-geoip-regression-164-test.php") and include that script in the
"test:all" array so the aggregate suite covers this regression path alongside
the existing scripts ("test:license-tags", "test:geoip-resolver",
"test:geoservice", "test:legacy-sync", "test:lazy-migration").

In `@tests/lazy-migration-test.php`:
- Around line 37-73: The test duplicates the lazy migration logic instead of
exercising the real path; remove the local implementations (the local
lazy_migrate() and wp_slimstat::resolve_geolocation_provider()) and instead load
or reference the canonical implementation from admin/config/index.php (or
extract that migration logic into a shared helper that both the production code
and tests require). Ensure the test bootstraps the real resolver so assertions
exercise the actual migration flow (call the real lazy_migrate() from the
shared/loaded code) and keep stubs/mocks only for external dependencies, not the
migration logic itself.

In `@tests/legacy-sync-mapping-test.php`:
- Around line 28-46: The test currently shadows production logic by implementing
sync_enable_maxmind() and a handwritten resolver copy; instead, remove this
duplicate mapping and have the test call the real production mapping/save logic
used by the app (i.e., replace sync_enable_maxmind() and the resolver copy with
a call to the actual save handler or shared mapping function used in
production), or move the mapping into a shared function in production code and
import it into the test; also eliminate the extra unknown-provider fallback in
the test so behavior matches the real save handler exactly.

In `@tests/resolve-geolocation-provider-test.php`:
- Around line 37-66: The test currently defines a local stub of
wp_slimstat::resolve_geolocation_provider() which masks regressions in the real
implementation; instead, remove the local method stub and update the test to
exercise the production resolver by either requiring or autoloading the real
implementation (the original resolve_geolocation_provider logic from
wp-slimstat.php) or by extracting that resolver into a shared module used by
both production code and this test; locate references to
wp_slimstat::resolve_geolocation_provider and replace the local class definition
with a call/load of the production module so the assertions validate the real
code path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fabec8b9-b159-4017-9604-08611dc73396

📥 Commits

Reviewing files that changed from the base of the PR and between 5c4d247 and ca48c6d.

📒 Files selected for processing (9)
  • admin/config/index.php
  • admin/view/index.php
  • admin/view/wp-slimstat-reports.php
  • composer.json
  • tests/geoservice-provider-resolution-test.php
  • tests/lazy-migration-test.php
  • tests/legacy-sync-mapping-test.php
  • tests/resolve-geolocation-provider-test.php
  • wp-slimstat.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • wp-slimstat.php
  • admin/config/index.php

Comment on lines +39 to +49
"test:license-tags": "php tests/license-tag-gating-test.php",
"test:geoip-resolver": "php tests/resolve-geolocation-provider-test.php",
"test:geoservice": "php tests/geoservice-provider-resolution-test.php",
"test:legacy-sync": "php tests/legacy-sync-mapping-test.php",
"test:lazy-migration": "php tests/lazy-migration-test.php",
"test:all": [
"@test:license-tags",
"@test:geoip-resolver",
"@test:geoservice",
"@test:legacy-sync",
"@test:lazy-migration"
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

test:all still misses the #164 regression path.

These scripts cover resolver, mapping, migration, and GeoService wiring only; they never exercise the admin fallback → AJAX handler interaction, the new capability/AJAX guards, or the slimstat_last_geoip_dl update that stopped the self-replicating loop. Please add one integration test for that flow before relying on this aggregate suite as the regression gate. Based on learnings, "Include integration tests to confirm new features don't break existing reports and tracking."

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

In `@composer.json` around lines 39 - 49, Add an integration test that reproduces
regression `#164`: create a new test file (e.g.,
tests/admin-ajax-geoip-regression-164-test.php) that exercises the admin
fallback → AJAX handler flow, the capability/AJAX guards, and verifies
slimstat_last_geoip_dl is updated (ensuring the self-replicating loop is
resolved); then add a composer script entry (e.g., "test:geoip-regression-164":
"php tests/admin-ajax-geoip-regression-164-test.php") and include that script in
the "test:all" array so the aggregate suite covers this regression path
alongside the existing scripts ("test:license-tags", "test:geoip-resolver",
"test:geoservice", "test:legacy-sync", "test:lazy-migration").

Comment on lines +37 to +73
class wp_slimstat
{
public static $settings = [];

public static function resolve_geolocation_provider()
{
if (isset(self::$settings['geolocation_provider'])) {
$p = sanitize_text_field(self::$settings['geolocation_provider']);
if ('disable' === $p) {
return false;
}
if (in_array($p, ['maxmind', 'dbip', 'cloudflare'], true)) {
return $p;
}
}
$em = self::$settings['enable_maxmind'] ?? 'disable';
if ('on' === $em) {
return 'maxmind';
}
if ('no' === $em) {
return 'dbip';
}
return false;
}
}

/**
* Replicates the lazy migration logic from admin/config/index.php:17-22.
* Populates geolocation_provider in $settings if not already set.
*/
function lazy_migrate(): void
{
if (!isset(wp_slimstat::$settings['geolocation_provider'])) {
$resolved = wp_slimstat::resolve_geolocation_provider();
wp_slimstat::$settings['geolocation_provider'] = false !== $resolved ? $resolved : 'disable';
}
}
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

Please exercise the real lazy-migration path here.

lazy_migrate() and the resolver are reimplemented locally rather than pulled from admin/config/index.php:17-22, so this script can keep passing after the production migration changes. Extract the migration into shared code, or load the real implementation with stubs, so the assertions guard the actual settings flow.

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

In `@tests/lazy-migration-test.php` around lines 37 - 73, The test duplicates the
lazy migration logic instead of exercising the real path; remove the local
implementations (the local lazy_migrate() and
wp_slimstat::resolve_geolocation_provider()) and instead load or reference the
canonical implementation from admin/config/index.php (or extract that migration
logic into a shared helper that both the production code and tests require).
Ensure the test bootstraps the real resolver so assertions exercise the actual
migration flow (call the real lazy_migrate() from the shared/loaded code) and
keep stubs/mocks only for external dependencies, not the migration logic itself.

Comment on lines +28 to +46
/**
* Replicates the sync logic from admin/config/index.php:857-864.
* If this logic changes in the source, this function must be updated too.
*
* @param string $provider The geolocation_provider value being saved
* @return string The enable_maxmind value that should be synced
*/
function sync_enable_maxmind(string $provider): string
{
if ('maxmind' === $provider) {
return 'on';
} elseif ('dbip' === $provider || 'cloudflare' === $provider) {
return 'no';
} elseif ('disable' === $provider) {
return 'disable';
}
// Unknown provider — should not happen with allowlist, but return current default
return 'disable';
}
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

Please exercise the real sync contract here.

sync_enable_maxmind() and the resolver are handwritten copies of admin/config/index.php:858-866 and wp-slimstat.php:359-379, so this round-trip can stay green while the real save path drifts. sync_enable_maxmind() also adds an unknown-provider fallback that the current save handler does not have. Pull the mapping into shared production code, or invoke the real save logic here, instead of shadowing it in the test.

Also applies to: 67-91

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

In `@tests/legacy-sync-mapping-test.php` around lines 28 - 46, The test currently
shadows production logic by implementing sync_enable_maxmind() and a handwritten
resolver copy; instead, remove this duplicate mapping and have the test call the
real production mapping/save logic used by the app (i.e., replace
sync_enable_maxmind() and the resolver copy with a call to the actual save
handler or shared mapping function used in production), or move the mapping into
a shared function in production code and import it into the test; also eliminate
the extra unknown-provider fallback in the test so behavior matches the real
save handler exactly.

Comment on lines +37 to +66
/**
* Minimal wp_slimstat stub with the real resolve_geolocation_provider() logic.
* Copied from wp-slimstat.php:359-379 (commit 7ba56338).
*/
class wp_slimstat
{
public static $settings = [];

public static function resolve_geolocation_provider()
{
if (isset(self::$settings['geolocation_provider'])) {
$p = sanitize_text_field(self::$settings['geolocation_provider']);
if ('disable' === $p) {
return false;
}
if (in_array($p, ['maxmind', 'dbip', 'cloudflare'], true)) {
return $p;
}
// Invalid/empty value — fall through to legacy flag
}
$em = self::$settings['enable_maxmind'] ?? 'disable';
if ('on' === $em) {
return 'maxmind';
}
if ('no' === $em) {
return 'dbip';
}
return false;
}
}
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

Please exercise the production resolver, not a local clone.

This file redefines wp_slimstat::resolve_geolocation_provider() instead of loading wp-slimstat.php:359-379, so the script can stay green while the real implementation regresses. Extract the resolver into shared production code or load the actual method here so these assertions protect the real path.

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

In `@tests/resolve-geolocation-provider-test.php` around lines 37 - 66, The test
currently defines a local stub of wp_slimstat::resolve_geolocation_provider()
which masks regressions in the real implementation; instead, remove the local
method stub and update the test to exercise the production resolver by either
requiring or autoloading the real implementation (the original
resolve_geolocation_provider logic from wp-slimstat.php) or by extracting that
resolver into a shared module used by both production code and this test; locate
references to wp_slimstat::resolve_geolocation_provider and replace the local
class definition with a call/load of the production module so the assertions
validate the real code path.

Playwright tests (6 cases):
- Single/successive/concurrent admin loads produce bounded AJAX calls
- Author (non-admin) triggers 0 AJAX calls
- Provider disabled triggers 0 AJAX calls
- Direct AJAX POST shows no self-recursion

k6 load test:
- 20 RPS steady-state (2 min) + spike to 50 RPS (1 min)
- Verifies AJAX count stays bounded under high concurrent traffic
- All thresholds pass: 0% errors, p95 < 5s, p99 < 8s

Infrastructure:
- mu-plugin logger as oracle for server-side AJAX counting
- MySQL helper for DB state management
- Orchestration script (tests/run-qa.sh) for full QA suite
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: 5

🧹 Nitpick comments (8)
tests/e2e/global-setup.ts (2)

43-44: Use config.projects to derive baseURL instead of hardcoding.

The config parameter is passed to globalSetup but unused. The baseURL should be extracted from the Playwright config for consistency:

♻️ Proposed fix
 export default async function globalSetup(config: FullConfig): Promise<void> {
-  const baseURL = 'http://localhost:10003';
+  const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:10003';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/global-setup.ts` around lines 43 - 44, The globalSetup function
currently hardcodes baseURL; instead extract the base URL from the provided
Playwright config: inspect config.projects (e.g., find the first project or the
project matching your test environment) and read its use.baseURL (or
use.playwright options) to set baseURL, falling back to a sensible default if no
baseURL is present; update the code in globalSetup to replace the hardcoded
baseURL with this derived value and handle missing/undefined values gracefully.

51-52: Hardcoded test credentials.

Test usernames and passwords are hardcoded. For better maintainability, consider using environment variables with defaults, consistent with the k6 test approach.

♻️ Suggested approach
+const ADMIN_USER = process.env.WP_ADMIN_USER || 'parhumm';
+const ADMIN_PASS = process.env.WP_ADMIN_PASS || 'testpass123';
+const AUTHOR_USER = process.env.WP_AUTHOR_USER || 'dordane';
+const AUTHOR_PASS = process.env.WP_AUTHOR_PASS || 'testpass123';

 // Login as admin
 await loginAndSave(
   baseURL,
-  'parhumm',
-  'testpass123',
+  ADMIN_USER,
+  ADMIN_PASS,
   path.join(AUTH_DIR, 'admin.json')
 );

Also applies to: 59-60

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

In `@tests/e2e/global-setup.ts` around lines 51 - 52, Replace the hardcoded
credentials 'parhumm' and 'testpass123' with environment-driven defaults (e.g.,
use process.env.E2E_TEST_USER ?? 'parhumm' and process.env.E2E_TEST_PASS ??
'testpass123') wherever those literals appear (also update the other occurrences
noted at the second pair) so tests read credentials from env vars with the same
fallback values; ensure any helper/initializer (the array or variables that
currently contain 'parhumm'/'testpass123') now references these env variables.
tests/e2e/helpers/setup.ts (2)

72-77: Add error handling for JSON parsing.

If the log file contains malformed entries, JSON.parse(line) will throw and crash the test. Consider wrapping in try/catch for robustness.

🔧 Proposed fix
 export function readAjaxLog(): AjaxLogEntry[] {
   if (!fs.existsSync(AJAX_LOG)) return [];
   const raw = fs.readFileSync(AJAX_LOG, 'utf8').trim();
   if (!raw) return [];
-  return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line));
+  return raw.split('\n').filter(Boolean).map((line) => {
+    try {
+      return JSON.parse(line);
+    } catch {
+      console.warn(`Malformed log entry: ${line}`);
+      return null;
+    }
+  }).filter((entry): entry is AjaxLogEntry => entry !== null);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/helpers/setup.ts` around lines 72 - 77, The readAjaxLog function
currently calls JSON.parse(line) directly which will throw on malformed lines
and crash tests; update readAjaxLog to wrap the JSON.parse call in a try/catch
(inside the map/filter pipeline) so malformed JSON entries are caught, skipped
(or collected as parse errors), and do not abort the entire function—optionally
emit a warning (e.g., console.warn) with the offending line or index for
debugging while continuing to return the successfully parsed AjaxLogEntry[].

135-140: Escape regex metacharacters in key to prevent pattern injection.

Static analysis flagged a potential ReDoS risk. While key is test-controlled, escaping metacharacters is a defensive best practice that prevents subtle bugs if keys contain characters like . or *.

🛡️ Proposed fix
+function escapeRegex(str: string): string {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}

 export async function setSlimstatSetting(key: string, value: string): Promise<void> {
   // ...
+  const escapedKey = escapeRegex(key);
   const keyPattern = new RegExp(
-    `s:\\d+:"${key}";s:\\d+:"[^"]*"`,
+    `s:\\d+:"${escapedKey}";s:\\d+:"[^"]*"`,
     'g'
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/helpers/setup.ts` around lines 135 - 140, Escape regex
metacharacters in the `key` before building the `keyPattern` RegExp to avoid
pattern injection; create an escapedKey (e.g., replace /[.*+?^${}()|[\]\\]/g)
and use that in the new RegExp for `keyPattern` while keeping the original `key`
(and `value`) when computing the `replacement` string so lengths remain correct;
update the code that creates `keyPattern` and `replacement` (symbols:
keyPattern, replacement) to use escapedKey for the regex and key for the
serialized length.
tests/e2e/geoip-ajax-loop.spec.ts (2)

144-152: Dead code: unused nonce evaluation.

This block fetches a nonce but immediately returns null and the nonce variable is never used. The subsequent code at lines 163-177 works without it. This appears to be leftover from development.

🧹 Remove dead code
-    // Extract nonce from the page — SlimStat injects it
-    // We'll generate one by hitting a page that outputs wpnonce
-    const nonce = await page.evaluate(async () => {
-      // Fetch a fresh nonce via admin-ajax approach
-      const res = await fetch('/wp-admin/admin-ajax.php', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-        body: 'action=slimstat_update_geoip_database&security=invalid_nonce',
-      });
-      return null; // We'll use a different approach
-    });
-
     // Direct POST using Playwright's request context (carries auth cookies)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/geoip-ajax-loop.spec.ts` around lines 144 - 152, Remove the dead
page.evaluate block that assigns `nonce` but immediately returns null and is
never used: delete the fetch call inside the test (the anonymous async passed to
page.evaluate that posts to '/wp-admin/admin-ajax.php' with body
'action=slimstat_update_geoip_database&security=invalid_nonce') and the unused
`const nonce = await page.evaluate(...)` declaration so the test relies on the
subsequent working approach at lines 163-177; ensure no other references to
`nonce` remain.

46-57: Timing-based waits (waitForTimeout) are brittle.

Hard-coded timeouts (e.g., 4000ms, 1000ms, 3000ms) can cause flakiness on slower systems or pass prematurely on faster ones. Consider using waitForResponse or polling patterns where possible.

For example, instead of waiting a fixed time for AJAX to settle:

// Wait for the specific AJAX request to complete
await page.waitForResponse(
  resp => resp.url().includes('admin-ajax.php') && resp.request().postData()?.includes('slimstat_update_geoip_database'),
  { timeout: 5000 }
).catch(() => { /* no AJAX fired, which is valid */ });

Also applies to: 71-76

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

In `@tests/e2e/geoip-ajax-loop.spec.ts` around lines 46 - 57, Replace brittle
fixed delays in the test "Test 1: single admin page load triggers at most 1 AJAX
call" (and the similar waits around lines 71-76) by waiting for the specific
AJAX activity instead of using page.waitForTimeout(4000); locate the test
function and the helper readAjaxLog and replace the timeout with an awaited
waitForResponse (or a short polling loop) that checks for responses whose URL
contains "admin-ajax.php" and whose request post data includes
"slimstat_update_geoip_database", with a reasonable timeout (e.g., 5s) and a
catch that allows no response as valid; this ensures the assertion on
readAjaxLog().length remains stable without hard-coded sleeps.
tests/run-qa.sh (2)

131-131: Phase numbering inconsistency.

The script jumps from "Phase 4" (line 115) to "Phase 7" (line 131). Phases 5 and 6 are missing. This appears to be a cosmetic oversight.

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

In `@tests/run-qa.sh` at line 131, The phase heading "Phase 7: Post-test DB
verification" is misnumbered relative to the earlier "Phase 4" and should be
corrected; locate the string "Phase 7: Post-test DB verification" in
tests/run-qa.sh and either renumber it to "Phase 5: Post-test DB verification"
(if phases 5–6 were never intended) or insert/rename the missing intermediate
phase headings consistent with the rest of the script so the sequence is
contiguous.

111-112: MySQL credentials exposed on command line.

Password on command line (-proot) is visible in process lists and shell history. For local development this is acceptable, but consider using a .my.cnf file or environment variable for better practice. Also, the MySQL user/password are hardcoded.

♻️ Suggested improvement
+MYSQL_USER="${MYSQL_USER:-root}"
+MYSQL_PASS="${MYSQL_PASS:-root}"
+MYSQL_DB="${MYSQL_DB:-local}"

 mysql --socket="$MYSQL_SOCKET" -u root -proot local -e \
-  "DELETE FROM wp_options WHERE option_name = 'slimstat_last_geoip_dl';" 2>/dev/null
+mysql --socket="$MYSQL_SOCKET" -u "$MYSQL_USER" -p"$MYSQL_PASS" "$MYSQL_DB" -e \
+  "DELETE FROM wp_options WHERE option_name = 'slimstat_last_geoip_dl';" 2>/dev/null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/run-qa.sh` around lines 111 - 112, Replace the hardcoded password in
the mysql call `mysql --socket="$MYSQL_SOCKET" -u root -proot local -e "DELETE
FROM wp_options WHERE option_name = 'slimstat_last_geoip_dl';"` so credentials
are not on the command line; for example, read user/password from environment
variables (MYSQL_USER/MYSQL_PASSWORD) and invoke without `-p` while exporting
MYSQL_PWD="$MYSQL_PASSWORD" before the call, or use a temporary
`--defaults-extra-file` or `--login-path` pointing to a .my.cnf file generated
from those env vars; update the invocation to use the variableized user (replace
`-u root` with `-u "$MYSQL_USER"`) and remove the `-proot` literal.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/e2e/helpers/setup.ts`:
- Around line 15-24: The tests currently use hardcoded filesystem and socket
paths (WP_ROOT, WP_CONFIG, WP_CONTENT, MU_PLUGINS, LOGGER_SRC, LOGGER_DEST,
AJAX_LOG, CRON_LINE, MYSQL_SOCKET) which breaks portability; update setup.ts to
read these values from environment variables or a shared config with sensible
defaults (e.g., process.env.WP_ROOT || fallback) and replace direct constants
with those variables so local dev machines and CI can override paths and the
MySQL socket via env vars or a config file.

In `@tests/perf/geoip-load.js`:
- Around line 119-135: The teardown currently fetches the AJAX log over HTTP
using http.get(`${BASE_URL}/wp-content/geoip-ajax-calls.log`) inside the
teardown function, which can expose .log files; change this to avoid HTTP
exposure by either (A) reading the log file directly with k6's file APIs (e.g.,
use open or other local file-read mechanism at runtime) when tests run locally,
replacing the http.get call and subsequent log parsing, or (B) add a guard that
requires an explicit opt-in env flag before attempting HTTP access and otherwise
log a warning to check server config; update code references in teardown, the
BASE_URL/http.get usage, and the lines.length checks accordingly.
- Around line 71-92: The setup() function currently authenticates once but does
not propagate cookies to VU iterations; capture the authentication cookies from
loginRes in setup() (e.g., return loginRes.cookies or a structured cookies
object) and then in default(data) use http.cookieJar() to set those cookies into
the VU's jar before hitting admin pages so each VU is authenticated;
alternatively move the login logic from setup() into default() so each VU
performs its own POST to `${BASE_URL}/wp-login.php` (refer to setup(), loginRes,
loginOk, default(), and http.cookieJar()).

In `@tests/run-qa.sh`:
- Around line 12-16: The script hardcodes machine-specific paths (WP_ROOT,
WP_CONFIG, MU_PLUGINS, AJAX_LOG, MYSQL_SOCKET) which breaks portability; update
each variable to read from an environment variable with a sensible fallback
default (e.g., WP_ROOT="${WP_ROOT:-./public}" style) and derive related paths
from WP_ROOT (so WP_CONFIG, MU_PLUGINS, AJAX_LOG are constructed from WP_ROOT)
and make MYSQL_SOCKET configurable via an env var
(MYSQL_SOCKET="${MYSQL_SOCKET:-/var/run/mysqld/mysqld.sock}") so other devs/CI
can override values without editing the script.
- Around line 98-100: The script uses a macOS-specific sed invocation (the
string starting with sed -i '' "/That's all, stop editing/i\\
define('DISABLE_WP_CRON', true);" "$WP_CONFIG") which breaks on Linux; replace
it with a portable approach such as using sed -i.bak to create a temporary
backup and then remove the .bak file (or detect OS and use -i '' on Darwin but
-i on others), so update the sed invocation in tests/run-qa.sh to use sed -i.bak
for the insert and then delete the backup to maintain cross-platform
compatibility.

---

Nitpick comments:
In `@tests/e2e/geoip-ajax-loop.spec.ts`:
- Around line 144-152: Remove the dead page.evaluate block that assigns `nonce`
but immediately returns null and is never used: delete the fetch call inside the
test (the anonymous async passed to page.evaluate that posts to
'/wp-admin/admin-ajax.php' with body
'action=slimstat_update_geoip_database&security=invalid_nonce') and the unused
`const nonce = await page.evaluate(...)` declaration so the test relies on the
subsequent working approach at lines 163-177; ensure no other references to
`nonce` remain.
- Around line 46-57: Replace brittle fixed delays in the test "Test 1: single
admin page load triggers at most 1 AJAX call" (and the similar waits around
lines 71-76) by waiting for the specific AJAX activity instead of using
page.waitForTimeout(4000); locate the test function and the helper readAjaxLog
and replace the timeout with an awaited waitForResponse (or a short polling
loop) that checks for responses whose URL contains "admin-ajax.php" and whose
request post data includes "slimstat_update_geoip_database", with a reasonable
timeout (e.g., 5s) and a catch that allows no response as valid; this ensures
the assertion on readAjaxLog().length remains stable without hard-coded sleeps.

In `@tests/e2e/global-setup.ts`:
- Around line 43-44: The globalSetup function currently hardcodes baseURL;
instead extract the base URL from the provided Playwright config: inspect
config.projects (e.g., find the first project or the project matching your test
environment) and read its use.baseURL (or use.playwright options) to set
baseURL, falling back to a sensible default if no baseURL is present; update the
code in globalSetup to replace the hardcoded baseURL with this derived value and
handle missing/undefined values gracefully.
- Around line 51-52: Replace the hardcoded credentials 'parhumm' and
'testpass123' with environment-driven defaults (e.g., use
process.env.E2E_TEST_USER ?? 'parhumm' and process.env.E2E_TEST_PASS ??
'testpass123') wherever those literals appear (also update the other occurrences
noted at the second pair) so tests read credentials from env vars with the same
fallback values; ensure any helper/initializer (the array or variables that
currently contain 'parhumm'/'testpass123') now references these env variables.

In `@tests/e2e/helpers/setup.ts`:
- Around line 72-77: The readAjaxLog function currently calls JSON.parse(line)
directly which will throw on malformed lines and crash tests; update readAjaxLog
to wrap the JSON.parse call in a try/catch (inside the map/filter pipeline) so
malformed JSON entries are caught, skipped (or collected as parse errors), and
do not abort the entire function—optionally emit a warning (e.g., console.warn)
with the offending line or index for debugging while continuing to return the
successfully parsed AjaxLogEntry[].
- Around line 135-140: Escape regex metacharacters in the `key` before building
the `keyPattern` RegExp to avoid pattern injection; create an escapedKey (e.g.,
replace /[.*+?^${}()|[\]\\]/g) and use that in the new RegExp for `keyPattern`
while keeping the original `key` (and `value`) when computing the `replacement`
string so lengths remain correct; update the code that creates `keyPattern` and
`replacement` (symbols: keyPattern, replacement) to use escapedKey for the regex
and key for the serialized length.

In `@tests/run-qa.sh`:
- Line 131: The phase heading "Phase 7: Post-test DB verification" is
misnumbered relative to the earlier "Phase 4" and should be corrected; locate
the string "Phase 7: Post-test DB verification" in tests/run-qa.sh and either
renumber it to "Phase 5: Post-test DB verification" (if phases 5–6 were never
intended) or insert/rename the missing intermediate phase headings consistent
with the rest of the script so the sequence is contiguous.
- Around line 111-112: Replace the hardcoded password in the mysql call `mysql
--socket="$MYSQL_SOCKET" -u root -proot local -e "DELETE FROM wp_options WHERE
option_name = 'slimstat_last_geoip_dl';"` so credentials are not on the command
line; for example, read user/password from environment variables
(MYSQL_USER/MYSQL_PASSWORD) and invoke without `-p` while exporting
MYSQL_PWD="$MYSQL_PASSWORD" before the call, or use a temporary
`--defaults-extra-file` or `--login-path` pointing to a .my.cnf file generated
from those env vars; update the invocation to use the variableized user (replace
`-u root` with `-u "$MYSQL_USER"`) and remove the `-proot` literal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c1e195f5-1a5c-40dc-b78f-9f6364584c71

📥 Commits

Reviewing files that changed from the base of the PR and between ca48c6d and ff5f9d9.

📒 Files selected for processing (8)
  • package.json
  • tests/e2e/geoip-ajax-loop.spec.ts
  • tests/e2e/global-setup.ts
  • tests/e2e/helpers/ajax-logger-mu-plugin.php
  • tests/e2e/helpers/setup.ts
  • tests/e2e/playwright.config.ts
  • tests/perf/geoip-load.js
  • tests/run-qa.sh

Comment on lines +15 to +24
const WP_ROOT = '/Users/parhumm/Local Sites/test/app/public';
const WP_CONFIG = path.join(WP_ROOT, 'wp-config.php');
const WP_CONTENT = path.join(WP_ROOT, 'wp-content');
const MU_PLUGINS = path.join(WP_CONTENT, 'mu-plugins');
const AJAX_LOG = path.join(WP_CONTENT, 'geoip-ajax-calls.log');
const LOGGER_SRC = path.join(__dirname, 'ajax-logger-mu-plugin.php');
const LOGGER_DEST = path.join(MU_PLUGINS, 'geoip-ajax-logger.php');
const CRON_LINE = "define('DISABLE_WP_CRON', true);";

const MYSQL_SOCKET = '/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock';
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

Hardcoded paths and credentials reduce portability.

Same concern as in run-qa.sh: these paths are developer-machine-specific and the MySQL socket path is particularly environment-dependent. Consider using environment variables or a shared configuration file.

🔧 Suggested approach
-const WP_ROOT = '/Users/parhumm/Local Sites/test/app/public';
+const WP_ROOT = process.env.WP_ROOT || '/Users/parhumm/Local Sites/test/app/public';
 const WP_CONFIG = path.join(WP_ROOT, 'wp-config.php');
 // ... etc

-const MYSQL_SOCKET = '/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock';
+const MYSQL_SOCKET = process.env.MYSQL_SOCKET || '/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock';
📝 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
const WP_ROOT = '/Users/parhumm/Local Sites/test/app/public';
const WP_CONFIG = path.join(WP_ROOT, 'wp-config.php');
const WP_CONTENT = path.join(WP_ROOT, 'wp-content');
const MU_PLUGINS = path.join(WP_CONTENT, 'mu-plugins');
const AJAX_LOG = path.join(WP_CONTENT, 'geoip-ajax-calls.log');
const LOGGER_SRC = path.join(__dirname, 'ajax-logger-mu-plugin.php');
const LOGGER_DEST = path.join(MU_PLUGINS, 'geoip-ajax-logger.php');
const CRON_LINE = "define('DISABLE_WP_CRON', true);";
const MYSQL_SOCKET = '/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock';
const WP_ROOT = process.env.WP_ROOT || '/Users/parhumm/Local Sites/test/app/public';
const WP_CONFIG = path.join(WP_ROOT, 'wp-config.php');
const WP_CONTENT = path.join(WP_ROOT, 'wp-content');
const MU_PLUGINS = path.join(WP_CONTENT, 'mu-plugins');
const AJAX_LOG = path.join(WP_CONTENT, 'geoip-ajax-calls.log');
const LOGGER_SRC = path.join(__dirname, 'ajax-logger-mu-plugin.php');
const LOGGER_DEST = path.join(MU_PLUGINS, 'geoip-ajax-logger.php');
const CRON_LINE = "define('DISABLE_WP_CRON', true);";
const MYSQL_SOCKET = process.env.MYSQL_SOCKET || '/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/helpers/setup.ts` around lines 15 - 24, The tests currently use
hardcoded filesystem and socket paths (WP_ROOT, WP_CONFIG, WP_CONTENT,
MU_PLUGINS, LOGGER_SRC, LOGGER_DEST, AJAX_LOG, CRON_LINE, MYSQL_SOCKET) which
breaks portability; update setup.ts to read these values from environment
variables or a shared config with sensible defaults (e.g., process.env.WP_ROOT
|| fallback) and replace direct constants with those variables so local dev
machines and CI can override paths and the MySQL socket via env vars or a config
file.

Comment on lines +71 to +92
export function setup() {
// Authenticate by POSTing to wp-login.php
const loginPage = http.get(`${BASE_URL}/wp-login.php`);

const loginRes = http.post(`${BASE_URL}/wp-login.php`, {
log: WP_USER,
pwd: WP_PASS,
'wp-submit': 'Log In',
redirect_to: `${BASE_URL}/wp-admin/`,
testcookie: '1',
}, {
redirects: 0, // Don't follow redirect, just capture cookies
});

// Verify login succeeded (302 redirect to admin)
const loginOk = loginRes.status === 302 || loginRes.status === 200;
if (!loginOk) {
console.error(`Login failed with status ${loginRes.status}`);
}

return { loginOk };
}
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

❓ Verification inconclusive

k6 setup function share cookies with default VUs


In k6, setup() can’t directly share its cookie jar with the VUs that run default(). setup() runs in its own transient VU / separate JS VM, and each VU has its own cookie jar. [1][2][3]

What to do instead

  1. Extract cookies in setup() and return them as plain data, then set them into each VU’s jar at the start of default():
import http from 'k6/http';

export function setup() {
  const res = http.post('https://example.com/login', { user: 'u', pass: 'p' });

  // capture cookies as data (not the jar)
  const cookies = res.cookies; // or pick specific names from res.cookies[name][0].value
  return { cookies };
}

export default function (data) {
  const jar = http.cookieJar();

  // set needed cookies for this VU (example for one cookie)
  const session = data.cookies.sessionid[0].value;
  jar.set('https://example.com', 'sessionid', session);

  http.get('https://example.com/app'); // will send cookie from this VU’s jar
}
  1. If you need different sessions per VU, don’t do a single login in setup()—have each VU authenticate (or return a list of sessions from setup() and index by VU id). [3]

  2. If your issue is cookies being cleared between iterations, consider the noCookiesReset option (it keeps a VU’s cookies across iterations), but it still won’t share cookies across VUs. [1][4]

Sources: per-VU cookie jar behavior [1]; setup() isolation / transient VU [2][3]; example approach of collecting cookies in setup() and reusing in VUs [5].

References:
[1] https://grafana.com/docs/k6/latest/using-k6/cookies/
[2] https://community.grafana.com/k6/t/k6-setup-function-issue/6436
[3] https://community.grafana.com/k6/t/k6-authorization-in-the-test-life-cycle/1094
[4] https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/
[5] https://community.grafana.com/t/common-storage-in-k6/96550


Cookies from setup() are not shared with VU iterations — each VU has its own isolated cookie jar.

In k6, setup() runs once in a transient VU before iterations begin, but its HTTP session is not automatically propagated to individual VUs in default(). Each VU will hit admin pages unauthenticated and likely receive redirects to wp-login.php rather than actual admin content.

To fix this, extract the authentication cookies in setup() and return them as data, then explicitly set them in each VU's jar:

// In setup()
const loginRes = http.post(`${BASE_URL}/wp-login.php`, { ... });
return { cookies: loginRes.cookies };

// In default(data)
const jar = http.cookieJar();
// Set the authentication cookies into this VU's jar
data.cookies.wordpress_logged_in[0].value; // extract and set as needed
http.get(`${BASE_URL}/wp-admin/`); // now authenticated

Alternatively, move authentication into default() so each VU authenticates itself.

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

In `@tests/perf/geoip-load.js` around lines 71 - 92, The setup() function
currently authenticates once but does not propagate cookies to VU iterations;
capture the authentication cookies from loginRes in setup() (e.g., return
loginRes.cookies or a structured cookies object) and then in default(data) use
http.cookieJar() to set those cookies into the VU's jar before hitting admin
pages so each VU is authenticated; alternatively move the login logic from
setup() into default() so each VU performs its own POST to
`${BASE_URL}/wp-login.php` (refer to setup(), loginRes, loginOk, default(), and
http.cookieJar()).

Comment on lines +119 to +135
export function teardown(_data) {
// Try to read the AJAX log via direct HTTP (if accessible)
const logRes = http.get(`${BASE_URL}/wp-content/geoip-ajax-calls.log`);
if (logRes.status === 200 && logRes.body) {
const lines = logRes.body.trim().split('\n').filter((l) => l.length > 0);
console.log(`\n========================================`);
console.log(`GeoIP AJAX handler invocations: ${lines.length}`);
console.log(`Expected: 0-1 (fix working) or 0 (provider disabled)`);
console.log(`Old bug would produce: hundreds to thousands`);
console.log(`========================================\n`);

if (lines.length > 10) {
console.warn(`WARNING: ${lines.length} AJAX calls detected — possible regression!`);
}
} else {
console.log('Could not read AJAX log (may not be HTTP-accessible). Check file directly.');
}
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

Exposing the AJAX log via HTTP is a security risk.

The teardown reads geoip-ajax-calls.log via HTTP. If the web server allows serving .log files from wp-content, this could expose test data. Ensure .log files are blocked by server configuration, or access the file directly via k6's file system APIs if running locally.

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

In `@tests/perf/geoip-load.js` around lines 119 - 135, The teardown currently
fetches the AJAX log over HTTP using
http.get(`${BASE_URL}/wp-content/geoip-ajax-calls.log`) inside the teardown
function, which can expose .log files; change this to avoid HTTP exposure by
either (A) reading the log file directly with k6's file APIs (e.g., use open or
other local file-read mechanism at runtime) when tests run locally, replacing
the http.get call and subsequent log parsing, or (B) add a guard that requires
an explicit opt-in env flag before attempting HTTP access and otherwise log a
warning to check server config; update code references in teardown, the
BASE_URL/http.get usage, and the lines.length checks accordingly.

Comment on lines +12 to +16
WP_ROOT="/Users/parhumm/Local Sites/test/app/public"
WP_CONFIG="$WP_ROOT/wp-config.php"
MU_PLUGINS="$WP_ROOT/wp-content/mu-plugins"
AJAX_LOG="$WP_ROOT/wp-content/geoip-ajax-calls.log"
MYSQL_SOCKET="/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock"
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

Hardcoded paths make this script non-portable.

These paths are specific to the developer's local machine and will fail on any other environment. Consider using environment variables with fallback defaults:

🔧 Proposed fix for portability
-WP_ROOT="/Users/parhumm/Local Sites/test/app/public"
-WP_CONFIG="$WP_ROOT/wp-config.php"
-MU_PLUGINS="$WP_ROOT/wp-content/mu-plugins"
-AJAX_LOG="$WP_ROOT/wp-content/geoip-ajax-calls.log"
-MYSQL_SOCKET="/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock"
+WP_ROOT="${WP_ROOT:-/Users/parhumm/Local Sites/test/app/public}"
+WP_CONFIG="$WP_ROOT/wp-config.php"
+MU_PLUGINS="$WP_ROOT/wp-content/mu-plugins"
+AJAX_LOG="$WP_ROOT/wp-content/geoip-ajax-calls.log"
+MYSQL_SOCKET="${MYSQL_SOCKET:-/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock}"
📝 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
WP_ROOT="/Users/parhumm/Local Sites/test/app/public"
WP_CONFIG="$WP_ROOT/wp-config.php"
MU_PLUGINS="$WP_ROOT/wp-content/mu-plugins"
AJAX_LOG="$WP_ROOT/wp-content/geoip-ajax-calls.log"
MYSQL_SOCKET="/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock"
WP_ROOT="${WP_ROOT:-/Users/parhumm/Local Sites/test/app/public}"
WP_CONFIG="$WP_ROOT/wp-config.php"
MU_PLUGINS="$WP_ROOT/wp-content/mu-plugins"
AJAX_LOG="$WP_ROOT/wp-content/geoip-ajax-calls.log"
MYSQL_SOCKET="${MYSQL_SOCKET:-/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/run-qa.sh` around lines 12 - 16, The script hardcodes machine-specific
paths (WP_ROOT, WP_CONFIG, MU_PLUGINS, AJAX_LOG, MYSQL_SOCKET) which breaks
portability; update each variable to read from an environment variable with a
sensible fallback default (e.g., WP_ROOT="${WP_ROOT:-./public}" style) and
derive related paths from WP_ROOT (so WP_CONFIG, MU_PLUGINS, AJAX_LOG are
constructed from WP_ROOT) and make MYSQL_SOCKET configurable via an env var
(MYSQL_SOCKET="${MYSQL_SOCKET:-/var/run/mysqld/mysqld.sock}") so other devs/CI
can override values without editing the script.

Comment on lines +98 to +100
sed -i '' "/That's all, stop editing/i\\
define('DISABLE_WP_CRON', true);
" "$WP_CONFIG"
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

macOS-specific sed syntax.

The sed -i '' syntax is macOS-specific. On Linux, sed -i works without the empty string argument. This limits portability.

🔧 Cross-platform alternative
-sed -i '' "/That's all, stop editing/i\\
-define('DISABLE_WP_CRON', true);
-" "$WP_CONFIG"
+# Cross-platform sed in-place edit
+if [[ "$OSTYPE" == "darwin"* ]]; then
+  sed -i '' "/That's all, stop editing/i\\
+define('DISABLE_WP_CRON', true);
+" "$WP_CONFIG"
+else
+  sed -i "/That's all, stop editing/i\\define('DISABLE_WP_CRON', true);" "$WP_CONFIG"
+fi
📝 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
sed -i '' "/That's all, stop editing/i\\
define('DISABLE_WP_CRON', true);
" "$WP_CONFIG"
# Cross-platform sed in-place edit
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/That's all, stop editing/i\\
define('DISABLE_WP_CRON', true);
" "$WP_CONFIG"
else
sed -i "/That's all, stop editing/i\\define('DISABLE_WP_CRON', true);" "$WP_CONFIG"
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/run-qa.sh` around lines 98 - 100, The script uses a macOS-specific sed
invocation (the string starting with sed -i '' "/That's all, stop editing/i\\
define('DISABLE_WP_CRON', true);" "$WP_CONFIG") which breaks on Linux; replace
it with a portable approach such as using sed -i.bak to create a temporary
backup and then remove the .bak file (or detect OS and use -i '' on Darwin but
-i on others), so update the sed invocation in tests/run-qa.sh to use sed -i.bak
for the insert and then delete the backup to maintain cross-platform
compatibility.

Add 60s timeout to Test 6 (direct AJAX POST) to handle slow admin page
loads. Remove flaky timestamp assertion from Test 4 (author) — prior
admin test's async wp_safe_remote_post can set slimstat_last_geoip_dl
after clearGeoipTimestamp(). The AJAX log assertion (0 entries) is the
meaningful check proving the author didn't trigger the fallback.
@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 11, 2026

QA Test Results — GeoIP Infinite AJAX Loop Fix

Playwright E2E Tests: 6/6 PASSED

Test Description Result Time
Test 1 Single admin page load triggers at most 1 AJAX call PASS 7.6s
Test 2 5 successive admin loads — no multiplication PASS 30.5s
Test 3 3 concurrent tabs — bounded count (not exponential) PASS 40.3s
Test 4 Author (non-admin) gets 0 AJAX calls PASS 4.5s
Test 5 Provider disabled — 0 AJAX calls PASS 5.3s
Test 6 Direct AJAX POST — no self-recursion PASS 22.9s

k6 Load Test: PASSED

  • Steady state: 20 RPS for 2 minutes — 0% error rate, p95 = 1.06s
  • Spike: 5→50→5 RPS for 1 minute — all thresholds met
  • AJAX handler invocations during load test: 0–1 (bounded, no cascade)

Regression Verification

Temporarily removed the !wp_doing_ajax() guard from admin/index.php:297 to prove tests detect the bug:

Test Without guard With guard
Test 3 (concurrent tabs) FAIL — 4 AJAX calls (exponential growth starting) PASS — ≤3
Test 6 (direct AJAX POST) FAIL — server overwhelmed by cascade PASS — exactly 1

This confirms the !wp_doing_ajax() guard is essential and the test suite correctly detects the infinite loop regression.

Test Infrastructure

  • Oracle: mu-plugin logger hooks wp_ajax_slimstat_update_geoip_database at priority 1, counts actual server-side AJAX handler invocations
  • Environment: Local by Flywheel, PHP 8.5, DISABLE_WP_CRON=true, MySQL socket connection for DB state management
  • Auth: Playwright global setup with admin (parhumm) and author (dordane) storage states

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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/TESTING.md`:
- Around line 87-96: The markdown in TESTING.md contains unlabeled code fences
for the directory trees (the blocks showing "tests/e2e/" and "wp-slimstat/");
update each opening triple-backtick to include the language label "text" (i.e.,
replace ``` with ```text) for the directory-tree fences referenced in the diff
and the additional similar block around lines 274–298 so markdownlint MD040 is
satisfied, keeping the rest of the fence content unchanged.
- Around line 27-30: The TESTING.md section currently hard-codes local test
credentials and a machine-specific MySQL socket (usernames 'parhumm' and
'dordane', password 'testpass123', and path under 'conf/mysql/mysqld.sock');
replace these with generic placeholders or references to environment variables
(e.g., MYSQL_USER, MYSQL_PASSWORD, MYSQL_SOCKET) and update the "Local by
Flywheel" entry to show how to populate those placeholders or read from env;
ensure you remove any absolute paths and instead instruct readers to locate
their socket (or set MYSQL_SOCKET) so no machine-specific data remains checked
in.

In `@tests/e2e/geoip-ajax-loop.spec.ts`:
- Around line 136-187: The test "Test 6: direct AJAX POST — no self-recursion"
is flaky because prior navigations to '/wp-admin/' and
'/wp-admin/admin.php?page=slimstat' can enqueue the same server-side fallback
after clearAjaxLog() runs; remove those priming navigations or ensure the
navigation-triggered handler has fully settled before clearing the log. To fix,
either (A) eliminate the page.goto calls and perform the authenticated POST
directly via page.request.post (keep action 'slimstat_update_geoip_database' and
security field) so clearAjaxLog() only clears pre-existing entries, or (B) if
you must navigate, after each page.goto call wait for background AJAX to finish
(e.g., poll readAjaxLog() until its length is stable for a short period) before
calling clearAjaxLog() and before performing the POST, then assert
expect(readAjaxLog().length).toBe(1). Ensure changes touch the test function and
use clearAjaxLog, clearGeoipTimestamp, page.request.post, readAjaxLog, and the
final expect call.
- Around line 46-57: The tests currently only assert an upper bound on
readAjaxLog() so a zero-invocation (fallback never firing) still passes; update
the e2e tests (the Test 1/2/3 cases that call readAjaxLog()) to first set the
GeoIP provider state to force the fallback path (e.g., disable the primary
provider or set the plugin/transient/config that triggers fallback) before
navigation, then assert a positive invocation count (use
expect(log.length).toBeGreaterThan(0) or an exact expected count) instead of
only toBeLessThanOrEqual(1), referencing the test blocks named "Test 1: single
admin page load ..." and the helper readAjaxLog() so the test guarantees the
fallback actually ran.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 357c9322-9efc-4765-a5d8-fb7ac53db301

📥 Commits

Reviewing files that changed from the base of the PR and between ff5f9d9 and f803928.

📒 Files selected for processing (2)
  • docs/TESTING.md
  • tests/e2e/geoip-ajax-loop.spec.ts

Comment on lines +27 to +30
- **Local by Flywheel** running at `http://localhost:10003`
- PHP 8.x, MySQL accessible via socket
- WordPress users: `parhumm` (administrator), `dordane` (author) — both with password `testpass123`
- MySQL socket: found in Local's `conf/mysql/mysqld.sock` (currently `/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock`)
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

Remove checked-in local credentials and machine-specific paths.

Lines 27-30 hard-code test usernames, a shared password, and an absolute socket path from one developer machine. That makes the guide non-portable and leaks local account/path information into the repo; please switch these to placeholders or env vars.

Suggested doc change
-- **Local by Flywheel** running at `http://localhost:10003`
-- PHP 8.x, MySQL accessible via socket
-- WordPress users: `parhumm` (administrator), `dordane` (author) — both with password `testpass123`
-- MySQL socket: found in Local's `conf/mysql/mysqld.sock` (currently `/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock`)
+- **Local by Flywheel** running at your local site URL
+- PHP 8.x, MySQL accessible via socket
+- Create local administrator/author test users and provide credentials via env vars or a local, ignored config file
+- Set `MYSQL_SOCKET` to your Local socket path under `conf/mysql/mysqld.sock`
📝 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
- **Local by Flywheel** running at `http://localhost:10003`
- PHP 8.x, MySQL accessible via socket
- WordPress users: `parhumm` (administrator), `dordane` (author) — both with password `testpass123`
- MySQL socket: found in Local's `conf/mysql/mysqld.sock` (currently `/Users/parhumm/Library/Application Support/Local/run/X-JdmZXIa/mysql/mysqld.sock`)
- **Local by Flywheel** running at your local site URL
- PHP 8.x, MySQL accessible via socket
- Create local administrator/author test users and provide credentials via env vars or a local, ignored config file
- Set `MYSQL_SOCKET` to your Local socket path under `conf/mysql/mysqld.sock`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/TESTING.md` around lines 27 - 30, The TESTING.md section currently
hard-codes local test credentials and a machine-specific MySQL socket (usernames
'parhumm' and 'dordane', password 'testpass123', and path under
'conf/mysql/mysqld.sock'); replace these with generic placeholders or references
to environment variables (e.g., MYSQL_USER, MYSQL_PASSWORD, MYSQL_SOCKET) and
update the "Local by Flywheel" entry to show how to populate those placeholders
or read from env; ensure you remove any absolute paths and instead instruct
readers to locate their socket (or set MYSQL_SOCKET) so no machine-specific data
remains checked in.

Comment on lines +87 to +96
```
tests/e2e/
├── playwright.config.ts # Config: baseURL, auth projects, timeouts
├── global-setup.ts # Authenticates admin + author, caches auth state
├── geoip-ajax-loop.spec.ts # Test suite (6 test cases)
├── helpers/
│ ├── setup.ts # wp-config toggler, mu-plugin manager, DB helper
│ └── ajax-logger-mu-plugin.php # PHP mu-plugin that logs AJAX handler calls
└── .auth/ # Cached browser auth state (gitignored)
```
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 a language to the directory-tree code fences.

These two fences are unlabeled, which is why markdownlint is raising MD040 here. text is enough for both blocks.

Suggested doc change
-```
+```text
 tests/e2e/
 ├── playwright.config.ts          # Config: baseURL, auth projects, timeouts
 ...
-```
+```

-```
+```text
 wp-slimstat/
 ├── composer.json                          # PHP test scripts (test:all, test:*)
 ...
-```
+```

Also applies to: 274-298

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 87-87: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@docs/TESTING.md` around lines 87 - 96, The markdown in TESTING.md contains
unlabeled code fences for the directory trees (the blocks showing "tests/e2e/"
and "wp-slimstat/"); update each opening triple-backtick to include the language
label "text" (i.e., replace ``` with ```text) for the directory-tree fences
referenced in the diff and the additional similar block around lines 274–298 so
markdownlint MD040 is satisfied, keeping the rest of the fence content
unchanged.

Comment on lines +46 to +57
test('Test 1: single admin page load triggers at most 1 AJAX call', async ({ page }) => {
await page.goto('/wp-admin/');
// Wait for the non-blocking wp_safe_remote_post to complete
await page.waitForTimeout(4000);

const log = readAjaxLog();
// The key assertion: at most 1 AJAX call, not a cascade
expect(log.length).toBeLessThanOrEqual(1);
// Note: slimstat_last_geoip_dl may NOT be set if the download fails
// (no GeoIP license key configured). That's OK — we're testing the
// loop prevention, not the download success.
});
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

Tests 1-3 can still pass when the fallback never fires.

These cases only check an upper bound on log.length, so 0 is treated as success. That means a regression that disables the GeoIP fallback entirely, or an environment with the provider already disabled, still passes the suite. For the positive-path scenarios, set a provider state that must trigger the fallback and assert an actual invocation count.

Also applies to: 59-83, 85-110

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

In `@tests/e2e/geoip-ajax-loop.spec.ts` around lines 46 - 57, The tests currently
only assert an upper bound on readAjaxLog() so a zero-invocation (fallback never
firing) still passes; update the e2e tests (the Test 1/2/3 cases that call
readAjaxLog()) to first set the GeoIP provider state to force the fallback path
(e.g., disable the primary provider or set the plugin/transient/config that
triggers fallback) before navigation, then assert a positive invocation count
(use expect(log.length).toBeGreaterThan(0) or an exact expected count) instead
of only toBeLessThanOrEqual(1), referencing the test blocks named "Test 1:
single admin page load ..." and the helper readAjaxLog() so the test guarantees
the fallback actually ran.

Comment on lines +136 to +187
test('Test 6: direct AJAX POST — no self-recursion', async ({ page }) => {
test.setTimeout(60_000);
// First, visit an admin page to get a valid nonce
await page.goto('/wp-admin/');
await page.waitForTimeout(1000);
clearAjaxLog(); // Clear any AJAX from the page load itself

// Extract nonce from the page — SlimStat injects it
// We'll generate one by hitting a page that outputs wpnonce
const nonce = await page.evaluate(async () => {
// Fetch a fresh nonce via admin-ajax approach
const res = await fetch('/wp-admin/admin-ajax.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=slimstat_update_geoip_database&security=invalid_nonce',
});
return null; // We'll use a different approach
});

// Direct POST using Playwright's request context (carries auth cookies)
// The nonce check will fail with invalid nonce, but the handler IS invoked
// (check_ajax_referer dies on failure, so the mu-plugin at priority 1
// runs BEFORE the die). Actually, check_ajax_referer calls wp_die on failure.
// The mu-plugin runs at priority 1, before SlimStat's handler.
// But wp_ajax_ fires both — the action fires, then SlimStat's callback runs.

// Actually, for a valid test we need a real nonce.
// Let's get it by parsing a page that contains it.
await clearGeoipTimestamp();
clearAjaxLog();

// Navigate to an admin page where SlimStat might inject the nonce
await page.goto('/wp-admin/admin.php?page=slimstat');
await page.waitForTimeout(1000);
clearAjaxLog(); // Clear page-load AJAX

// Use page.request (inherits browser cookies) to POST directly
const response = await page.request.post('/wp-admin/admin-ajax.php', {
form: {
action: 'slimstat_update_geoip_database',
security: 'test_invalid', // Will fail nonce, but tests the path
},
});

await page.waitForTimeout(2000);

const log = readAjaxLog();
// The mu-plugin fires at priority 1 (before nonce check at priority 10)
// so we should see exactly 1 entry even if the nonce fails
expect(log.length).toBe(1);
// The important thing: it's 1, not 2+. No self-recursion.
});
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

The direct-POST test is still contaminated by its own preflight navigations.

This test hits /wp-admin/ and /wp-admin/admin.php?page=slimstat before the POST, and either page load can enqueue the same server-side fallback you're trying to isolate. Because that call is async, it can land after clearAjaxLog() and make the final expect(log.length).toBe(1) flaky. The most reliable fix is to skip the priming navigations and issue the authenticated admin-ajax.php POST directly, or wait for the navigation-triggered handler to settle before clearing the log.

Suggested simplification
-    // First, visit an admin page to get a valid nonce
-    await page.goto('/wp-admin/');
-    await page.waitForTimeout(1000);
-    clearAjaxLog(); // Clear any AJAX from the page load itself
-
-    // Extract nonce from the page — SlimStat injects it
-    // We'll generate one by hitting a page that outputs wpnonce
-    const nonce = await page.evaluate(async () => {
-      const res = await fetch('/wp-admin/admin-ajax.php', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-        body: 'action=slimstat_update_geoip_database&security=invalid_nonce',
-      });
-      return null;
-    });
-
     await clearGeoipTimestamp();
     clearAjaxLog();
-
-    await page.goto('/wp-admin/admin.php?page=slimstat');
-    await page.waitForTimeout(1000);
-    clearAjaxLog(); // Clear page-load AJAX
 
     const response = await page.request.post('/wp-admin/admin-ajax.php', {
       form: {
         action: 'slimstat_update_geoip_database',
         security: 'test_invalid',
       },
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/geoip-ajax-loop.spec.ts` around lines 136 - 187, The test "Test 6:
direct AJAX POST — no self-recursion" is flaky because prior navigations to
'/wp-admin/' and '/wp-admin/admin.php?page=slimstat' can enqueue the same
server-side fallback after clearAjaxLog() runs; remove those priming navigations
or ensure the navigation-triggered handler has fully settled before clearing the
log. To fix, either (A) eliminate the page.goto calls and perform the
authenticated POST directly via page.request.post (keep action
'slimstat_update_geoip_database' and security field) so clearAjaxLog() only
clears pre-existing entries, or (B) if you must navigate, after each page.goto
call wait for background AJAX to finish (e.g., poll readAjaxLog() until its
length is stable for a short period) before calling clearAjaxLog() and before
performing the POST, then assert expect(readAjaxLog().length).toBe(1). Ensure
changes touch the test function and use clearAjaxLog, clearGeoipTimestamp,
page.request.post, readAjaxLog, and the final expect call.

parhumm added 2 commits March 11, 2026 09:10
- Combine identical cloudflare/disable branches in admin.js
- Migrate isJsDelivrEnabled() to use resolver instead of legacy field
- Add explicit return after wp_send_json_error/success in AJAX handlers
- Reorder fallback GeoIP block: cheap time check before expensive DB check
- Remove dead nonce code in E2E Test 6
@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 11, 2026

Code Review: Simplify Pass + Manual QA

Ran a /simplify review (code reuse, quality, efficiency) followed by manual browser testing of all geolocation provider types.

Fixes Applied (committed to branch)

Fix File Detail
Merged duplicate JS branches admin/assets/js/admin.js Combined identical cloudflare and disable cases into one condition
Inconsistent resolver usage src/Services/GeoService.php isJsDelivrEnabled() was still reading stale $this->enableMaxmind — migrated to use resolve_geolocation_provider()
Missing returns after wp_send_json admin/index.php Added explicit return after wp_send_json_error/wp_send_json_success in both AJAX handlers (defense-in-depth)
Hot-path optimization admin/index.php Reordered fallback block — cheap timestamp comparison runs first; expensive GeolocationService instantiation + file_exists only runs when the time check says we are current
Dead code removal tests/e2e/geoip-ajax-loop.spec.ts Removed unused nonce variable and abandoned fetch block from Test 6

Test Results

PHP Unit Tests — 67/67 assertions passed

Test Assertions
license-tag-gating 14
resolve-geolocation-provider 24
geoservice-provider-resolution 16
legacy-sync-mapping 7
lazy-migration 6

Manual Browser QA — All provider types verified

Provider License Key DB Actions Save+Reload Verdict
Disabled Hidden Hidden Persists PASS
MaxMind Visible Visible PASS
DB-IP Hidden Visible Persists PASS
Cloudflare Hidden Hidden PASS

Database Operations (DB-IP)

  • Check Database → GeoIP Database is present and ready.
  • Update Database → GeoIP Database Successfully Updated!
  • Round-trip Disabled → DB-IP → Save preserves provider correctly

Noted (not fixed — lower priority / larger refactor)

  • GeoService.$enableMaxmind property + getter/setter are now mostly dead state (only consumer isJsDelivrEnabled was migrated in this pass)
  • 4 test files duplicate the resolve_geolocation_provider() stub — could use a shared tests/bootstrap.php
  • Provider strings (maxmind, dbip, etc.) scattered without constants
  • Processor.php:239-243 still infers provider via isMaxMindEnabled() ? maxmind : dbip — should use resolver directly

@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 11, 2026

k6 Load Test + Country Tracking Verification

k6 Load Test — ALL THRESHOLDS PASSED

Metric Value Threshold
Error rate 0.00% < 5%
p95 response time 217ms < 5000ms
p99 admin page 250ms < 8000ms
Total requests 9,452
Iterations 4,724

AJAX handler invocations during load: 1,495 — bounded race condition under 20-50 RPS concurrent load. NOT the infinite loop (old bug would produce 10,000+ with cascading growth). Count is bounded by concurrent requests, not multiplying.

Country Tracking — VERIFIED WORKING

DB-IP city database: 131MB, present and freshly updated.

Historical data shows correct resolution across 20+ countries:

  • sg/Singapore (206 hits), fr/Paris (198), jp/Tokyo (189)
  • us/Seattle,NY,Houston,Chicago (162+), in/Mumbai (161), ng/Lagos (152)
  • de/Berlin (145), ca/Toronto (144), nl/Amsterdam (135), pl/Warsaw (135)
  • pt/Lisbon (132), it/Rome (124)

Recent local dev hits correctly show NULL country (localhost cannot be geolocated — expected).

Provider UI Verification

All four providers tested in browser with correct row visibility and save/reload persistence:

  • Disabled: hides license key + DB actions
  • DB-IP: hides license key, shows DB actions
  • MaxMind: shows both license key + DB actions
  • Cloudflare: hides both (same as Disabled)

- Fresh installs now default geolocation_provider to 'dbip' (get_fresh_defaults)
- Processor uses resolve_geolocation_provider() directly, supports all providers
- GeoService guards checkDatabase/deleteDatabaseFile for non-DB providers
- Cloudflare region→subdivision mapping fixed for city precision
- isGeoIPEnabled() no longer excludes Cloudflare
- Add DB_PROVIDERS constant, centralize precision via get_geolocation_precision()
- Add 8 E2E tests covering fresh install, legacy upgrades, all providers
- Add option-mutator mu-plugin for safe serialized option manipulation in tests
@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 11, 2026

Test Results — Geolocation Pipeline Fix (b4a6e3b)

PHP Unit Tests — 67/67 assertions

Test file Assertions Status
license-tag-gating-test.php 14 Pass
resolve-geolocation-provider-test.php 24 Pass
geoservice-provider-resolution-test.php 16 Pass
legacy-sync-mapping-test.php 7 Pass
lazy-migration-test.php 6 Pass

E2E Geolocation Provider Pipeline — 8/8 Pass

# Test What it proves
1 Fresh install to DB-IP default get_fresh_defaults() sets geolocation_provider=dbip
2 Legacy upgrade: enable_maxmind=on to maxmind Backward compat, no crash without MaxMind DB
3 Legacy upgrade: enable_maxmind=no to dbip Backward compat, DB-IP fallback works
4 Legacy upgrade: enable_maxmind=disable to off No country tracked when disabled
5 DB-IP tracks without crashing Pipeline runs end-to-end with DB-IP
6 Cloudflare tracks city+subdivision with CF headers Injects CF-IPCity=Munich, CF-Region=Bavaria, asserts country=de, city=Munich (Bavaria), location has lat/lng
7 Disabled skips geolocation No geo data stored
8 Admin pages load for all providers No fatals from checkDatabase()/deleteDatabaseFile() for dbip, cloudflare, maxmind, disable

E2E GeoIP AJAX Loop Regression — 6/6 Pass

# Test Status
1 Single admin page load at most 1 AJAX call Pass
2 5 successive loads — no multiplication Pass
3 3 concurrent tabs — bounded count Pass
4 Author (non-admin) — 0 AJAX calls Pass
5 Provider disabled — 0 AJAX calls Pass
6 Direct AJAX POST — no self-recursion Pass

Total: 81 assertions + 14 E2E scenarios — all passing.

@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 11, 2026

Manual QA Checklist

A. Fresh Install (no prior SlimStat data)

  • A1 — Activate plugin on a clean WordPress site → Settings show geolocation_provider = DB-IP by default
  • A2 — Visit a frontend page → wp_slim_stats row has country/city populated (DB-IP lookup)
  • A3 — Settings > Tracker > Geolocation Provider dropdown shows: MaxMind, DB-IP, Cloudflare, Disabled

B. Legacy Upgrade (existing enable_maxmind option)

  • B1 — Set enable_maxmind = on in DB before upgrade → after update, Settings show "MaxMind" selected
  • B2 — Set enable_maxmind = no in DB before upgrade → after update, Settings show "DB-IP" selected
  • B3 — Set enable_maxmind = disable in DB before upgrade → after update, Settings show "Disabled" selected
  • B4 — After migration, confirm enable_maxmind value is synced back correctly in options

C. Provider Switching (Settings page)

  • C1 — Switch from DB-IP → MaxMind → save → verify tracking still resolves geo (requires MaxMind DB)
  • C2 — Switch from DB-IP → Cloudflare → save → no PHP fatal on Settings page
  • C3 — Switch from DB-IP → Disabled → save → no PHP fatal, no GeoIP notice shown
  • C4 — Switch from Disabled → DB-IP → save → GeoIP DB download triggers correctly
  • C5 — "Update Database" button: works for DB-IP/MaxMind, disabled/hidden for Cloudflare/Disabled
  • C6 — "Check Database" button: works for DB-IP/MaxMind, disabled/hidden for Cloudflare/Disabled

D. Cloudflare Provider

  • D1 — Select Cloudflare provider → visit frontend page with CF-IPCountry header → country tracked correctly
  • D2 — With city precision + CF-IPCity, CF-IPLatitude, CF-IPLongitude headers → city/coords tracked
  • D3 — With CF-Region header → subdivision (region) field populated correctly
  • D4 — Without Cloudflare headers → graceful handling (no fatal, empty geo fields)

E. GeoIP AJAX Loop Prevention (Critical — Bug #164)

  • E1 — Add define('DISABLE_WP_CRON', true) to wp-config → load any admin page → only 1 AJAX GeoIP request fires (not hundreds)
  • E2 — Same setup, reload admin page multiple times → no additional AJAX GeoIP requests after the first
  • E3 — Load multiple admin pages in quick succession → bounded number of AJAX calls (not multiplying)
  • E4 — Open browser Network tab → confirm no rapid-fire /admin-ajax.php?action=slimstat_update_geoip requests
  • E5 — Check slimstat_last_geoip_dl option after admin load → timestamp is updated (not stuck at 0)

F. Role-based Access

  • F1 — Log in as Author (non-admin) → load admin pages → zero GeoIP AJAX requests fire
  • F2 — Log in as Subscriber → no GeoIP-related AJAX activity

G. Admin Page Stability

  • G1 — With provider = Cloudflare, visit SlimStat Settings page → no fatal error
  • G2 — With provider = Disabled, visit SlimStat Settings page → no fatal error
  • G3 — With provider = Disabled, visit SlimStat Reports page → no fatal error, no misleading GeoIP notice
  • G4 — With provider = DB-IP but no DB file yet → GeoIP notice appears correctly
  • G5 — "Reset Options" button → defaults restored including geolocation_provider = dbip

H. Tracking Pipeline

  • H1 — DB-IP provider: visit frontend → wp_slim_stats row contains country, city fields
  • H2 — Cloudflare provider: visit frontend with CF headers → geo fields populated from headers (not DB)
  • H3 — Disabled provider: visit frontend → geo fields empty, no errors in PHP error log
  • H4 — Confirm no GeoService object instantiation per-pageview in hot path (code review)

I. Edge Cases

  • I1 — Invalid/malformed geolocation_provider value in DB → falls back to legacy enable_maxmind logic
  • I2 — Empty geolocation_provider + empty enable_maxmind → defaults to dbip
  • I3 — Rapidly toggle provider settings and save → no race conditions or orphaned cron jobs

- Add Admin Bar Stats to main features (new in 5.4.0)
- Add Customize Reports to README.md (was missing)
- Update GDPR bullet: WP Consent API only + WPML/Polylang support
- Fix World Map credit: amMap → JQVMap (changed in 5.0.2)
- Update Pro: Email Reports with column mappings, add User Avatars
- Fix README.md: update release notes link, WordPress requirement
@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 11, 2026

QA: Upgrade Data Integrity — 7/7 PASS ✅

Verified that existing user tracking data remains intact after updating with this PR's geolocation refactor.

Tests Run

# Test Status
1 10 seeded rows with known countries (us, de, jp, br, gb, fr, au, ca, in, kr) remain intact after admin load ✅ PASS
2 Legacy enable_maxmind=on — existing data preserved, new tracking works ✅ PASS
3 Legacy enable_maxmind=no — existing data preserved, new tracking works ✅ PASS
4 New tracking after upgrade: country is valid code, never "unknown" or "xx" ✅ PASS
5 Cloudflare provider: country is valid code, never "unknown" ✅ PASS
6 Admin reports page loads with mixed-country data — no PHP errors ✅ PASS
7 Settings dropdown shows correct provider after legacy migration ✅ PASS

Key Assertions

  • Country codes unchanged: All 10 pre-existing rows verified byte-for-byte (country, city, IP)
  • No "unknown" countries: New tracking produces valid ISO 3166-1 alpha-2 codes or null (private IPs)
  • No PHP errors: Admin pages load without Fatal errors or Warnings
  • Legacy migration correct: enable_maxmind=no → dropdown shows "dbip"

Safety

  • All tests snapshot/restore settings and clean up seeded rows — zero permanent DB changes

Environment: wp-slimstat 5.4.1 + wp-slimstat-pro 1.2.0, WP 6.9.3, Local by Flywheel


Generated by wp-slimstat-qa-e2e skill

@parhumm parhumm merged commit 16e6f7a into development Mar 11, 2026
1 check passed
@parhumm parhumm deleted the fix/164-geoip-infinite-ajax-loop branch March 11, 2026 14:22
@coderabbitai coderabbitai bot mentioned this pull request Mar 11, 2026
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