Skip to content

Register Stream operations as WordPress Abilities#1859

Open
PatelUtkarsh wants to merge 24 commits intodevelopfrom
feature/XWPENG-13-abilities-api
Open

Register Stream operations as WordPress Abilities#1859
PatelUtkarsh wants to merge 24 commits intodevelopfrom
feature/XWPENG-13-abilities-api

Conversation

@PatelUtkarsh
Copy link
Copy Markdown
Member

@PatelUtkarsh PatelUtkarsh commented May 4, 2026

Resolves XWPENG-13.

Integrates Stream with the WordPress 6.9 Abilities API. Eleven Stream operations are registered as abilities under the stream/ namespace, exposed via the core Abilities REST controller at /wp-abilities/v1/abilities/stream/{slug}/run once a site owner explicitly opts in.

Approach

Mirrors Stream's existing connector/alert plugin extension layout: an abstract base class, a loader gated on environment + opt-in setting, and one file per ability that delegates to existing Stream internals (no business logic duplicated).

Permission model — read abilities use Stream's view_stream capability (via a shared trait) so editors and other roles configured under "Role Access" can call them, matching the admin UI. Write and destructive abilities require the Stream settings capability (WP_STREAM_SETTINGS_CAPABILITY, filterable; defaults to manage_options).

Schemas are hand-written with AI-readable description strings rather than auto-derived, decoupling the public API from internal arg names.

Abilities registered:

Ability Permission Backed by
stream/get-records view_stream Stream records query
stream/get-record view_stream Single record + meta
stream/get-settings settings cap Stream settings option
stream/get-alerts view_stream wp_stream_alerts posts + meta
stream/get-connectors view_stream Registered connectors + labels
stream/get-exclusion-rules view_stream Pivoted exclusion-rule rows
stream/create-alert settings cap New wp_stream_alerts post
stream/update-settings settings cap Partial-merge option update
stream/create-exclusion-rule settings cap Append to exclusion-rule columns
stream/purge-records settings cap Filtered cascade delete
stream/delete-alert settings cap Hard-delete wp_stream_alerts post

Routing follows the WP core Abilities REST controller defaults: read-only abilities are served as GET, write abilities as POST, and destructive + idempotent abilities as DELETE. Non-matching methods return 405 Method Not Allowed.

Gating

  1. WordPress version — silent no-op on WP < 6.9. The loader returns early in its constructor, the settings field is hidden in the Advanced section, and the abstract base's register() checks function_exists( 'wp_register_ability' ) defensively. No admin notice is shown (per direction).
  2. Settings toggle — new advanced_enable_abilities_api checkbox in the existing Advanced section of Stream settings. Defaults to 0. Only renders on WP 6.9+. Honors network option on network-activated multisite.

Safety

  • Read abilities use view_stream; write/destructive abilities use the Stream settings capability (filterable).
  • stream/purge-records requires confirm: true AND at least one filter (older_than_days, connector, context, action); a confirm-only payload returns a 400 WP_Error rather than truncating the table. On any multisite request that is not running inside Network Admin (REST never is), the purge is scoped to blog_id = get_current_blog_id() so it cannot wipe other sites' records.
  • stream/get-record applies the same per-blog scoping so a view_stream user on one site can't read records from other sites by guessing IDs.
  • stream/delete-alert is idempotent — the second call returns the same 404 WP_Error as a missing ID. Refuses to delete posts that are not of wp_stream_alerts type.
  • stream/create-alert validates alert_type against registered alert types and rejects unknown types.
  • stream/get-records schema tightened: orderby is an enum, *__in arrays have maxItems caps.
  • stream/create-exclusion-rule validates IP format at the application layer.
  • older_than_days cutoffs are computed as a UTC DateTime in PHP (matching Admin::purge_scheduled_action()) so behavior is independent of MySQL server timezone.

Manual REST QA

After merging and enabling the toggle on a WP 6.9+ site:

# List the eleven abilities.
curl -u admin:password 'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities?category=stream'

# Read connectors (works for any role with view_stream). Read abilities are GET.
curl -u editor:password \
  'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/get-connectors/run'

# Create an alert (requires settings cap). Write abilities are POST.
curl -u admin:password -X POST \
  'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/create-alert/run' \
  -H 'Content-Type: application/json' \
  -d '{"input":{"alert_type":"highlight","trigger_author":"any","trigger_context":"any","trigger_action":"any"}}'

# Filtered purge. Destructive + idempotent abilities are DELETE.
curl -u admin:password -X DELETE \
  'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/purge-records/run' \
  -H 'Content-Type: application/json' \
  -d '{"input":{"confirm":true,"older_than_days":90}}'

# Unauthenticated request — expect 401/403.
curl 'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/get-records/run'

Checklist

  • Project documentation has been updated to reflect the changes in this pull request, if applicable.
  • I have tested the changes in the local development environment (see contributing.md).
  • I have added phpunit tests.

Release Changelog

  • New: Register 11 Stream operations as WordPress 6.9 Abilities API abilities under the stream/ namespace, gated behind a new "Enable Abilities API" advanced setting (default off). Read abilities respect Stream's view_stream capability; write abilities require the Stream settings capability.

Add abstract Ability base class, Abilities loader with WP 6.9 + setting
gating, and a new "Enable Abilities API" toggle under the existing
Advanced settings section. The loader hooks wp_abilities_api_init and
will register concrete abilities once they are added in subsequent
commits. Falls back silently on WordPress < 6.9.
Implements the six read-only abilities under stream/* namespace:
get-records, get-record, get-settings, get-alerts, get-connectors,
and get-exclusion-rules. Each ability has hand-written JSON Schemas,
delegates to existing Stream APIs, and ships with a PHPUnit test
covering name, schema, permission gating, and execution.

Tests skip themselves on WordPress < 6.9 via the shared
Abilities_TestCase base class.
Add three abilities that mutate Stream state through the existing internal
APIs: stream/create-alert (creates a wp_stream_alerts CPT post with
alert_type and alert_meta), stream/update-settings (partial-merge update
to the wp_stream option), and stream/create-exclusion-rule (appends to
the parallel-array exclude_rules option columns Stream already uses).

Each ability is gated behind the manage_options capability. Hand-written
JSON schemas describe inputs and outputs for AI consumers; create-alert
requires the four trigger fields, create-exclusion-rule requires at
least one filter property, and update-settings requires a non-empty
settings map. Each ability ships with PHPUnit coverage that verifies
permissions, schema shape, and end-to-end execution against the option
or post store.
Add the two destructive abilities required by the ticket:
stream/purge-records (filtered DELETE against the Stream records table
with a cascading meta delete that mirrors Admin::erase()) and
stream/delete-alert (force-delete a wp_stream_alerts post by ID).

purge-records refuses to run unless confirm: true is supplied AND at
least one filter (older_than_days, connector, context, action) is set,
preventing an accidental full table wipe. The row count is computed
before the DELETE so the response is meaningful even though the
multi-table DELETE returns the combined affected rows. delete-alert
returns a 404 WP_Error when the ID is unknown or refers to a non-alert
post type, which makes the ability safely idempotent.

Both abilities ship with PHPUnit coverage that exercises permissions,
schema validation, the happy path, the refusal paths, and (for
purge-records) the meta cascade.
Cover the two infrastructure pieces left untested by the per-ability
suites: tests/phpunit/test-class-ability.php exercises the Ability
abstract base via an in-file Fake_Ability_For_Test subclass (verifies
get_meta() emits category and show_in_rest, conditionally adds an
annotations key, and that the default permission_callback denies
subscribers and grants admins; also asserts register() makes the
ability retrievable via wp_get_ability() when the API is available).

tests/phpunit/test-class-abilities.php covers the loader: is_available()
tracks the WP_Ability class presence, is_enabled() reflects the
advanced_enable_abilities_api option, the constructor only hooks
wp_abilities_api_init when both gates pass, get_ability_slugs() lists
all eleven slugs, load_abilities() instantiates each, and
register_abilities() does not double-load on a second invocation.

Resolves: XWPENG-13
@PatelUtkarsh PatelUtkarsh marked this pull request as draft May 4, 2026 09:45
@PatelUtkarsh PatelUtkarsh changed the title Register Stream operations as WordPress Abilities (XWPENG-13) Register Stream operations as WordPress Abilities May 4, 2026
- Register 'stream' category on wp_abilities_api_categories_init so
  abilities with category=stream pass core's category-existence check
- Move 'category' from meta to top-level args in Ability::register(),
  matching the wp_register_ability() contract in WP 6.9
- Replace get-record's broken DB::get_records(['record' => $id]) call
  (Query class never implemented the singular 'record' arg) with a
  direct $wpdb single-row lookup
- Snapshot/restore $plugin->settings->options in Abilities_TestCase so
  in-memory mutations from write-ability tests don't leak across tests
- Update tests to satisfy the doing_action() guards on
  wp_register_ability() and wp_register_ability_category()
Add a per-ability 'instructions' annotation: a 1-2 sentence note for
AI agents about when and how to call each ability, distinct from the
description (which describes what it does).

Add tests/phpunit/abilities/test-rest-integration.php covering all
three ability types end-to-end: dispatches actual WP_REST_Requests
through WP_REST_Server and asserts 200/403/404/405 paths plus the
list-abilities endpoint exposes all 11 stream/* abilities. Catches
breakage in the real REST stack that direct execute() tests miss.

Add idempotent: true to purge-records annotations. WP core's REST
router only routes to DELETE when destructive AND idempotent are
both true; without idempotent the controller expects POST.

Refactor test action-firing to use the documented core test pattern
of pushing onto $wp_current_filter rather than registering callbacks
through add_action(). Cleaner, no global hook pollution, matches the
convention used in WordPress core's own abilities-api tests.

Make Abilities::register_abilities() defensive: skip per-ability
register() calls when the ability is already registered, preventing
spurious _doing_it_wrong notices when load_abilities() runs more
than once in the same process.
WP core's WP_Ability::invoke_callback() spreads zero arguments into the
execute callback when the ability declares no input_schema (see
wp-includes/abilities-api/class-wp-ability.php:506-512). Our previous
'execute($input)' signature required one argument, so any GET request
to a no-input-schema ability raised a fatal ArgumentCountError and
returned HTTP 500 to the caller.

Add '$input = null' as the default on the abstract Ability::execute()
plus all 11 concrete subclasses and the test fake. Null matches WP
core's own conventions (their invoke_callback and check_permissions
both default $input to null). Abilities that DO declare an
input_schema continue to receive the parsed value verbatim from core,
so the default sits unused for those.

Caught by live e2e testing against WP 6.9.1 (Phase 4 of
XWPENG-13-e2e.md): get-settings, get-connectors, and
get-exclusion-rules previously fataled.
- Authorization: read abilities use 'view_stream'; base default uses
  WP_STREAM_SETTINGS_CAPABILITY. Abilities registers a user_has_cap
  filter for REST contexts where Admin (and its filter) isn't loaded,
  so allowed roles can call read abilities consistently with the UI.
- update-settings: allowlist {section}_{field} keys from registered
  fields, run incoming values through Settings::sanitize_settings(),
  reject payloads with no recognized keys.
- create-exclusion-rule: schema gains format:ip and maxLength bounds;
  execute() sanitizes via sanitize_text_field(), validates IPs with
  FILTER_VALIDATE_IP, validates connector against registered slugs,
  rejects all-empty payloads.
- purge-records: use rows_affected from the DELETE itself (no stale
  pre-count); run orphan-meta sweep after; fix MySQL alias syntax.
- get-record: kept direct query (Query::query has a real array_shift
  bug with record__in) but adds explicit blog_id scoping on multisite
  so cross-site record leakage cannot occur.
The 5 read-only abilities (get-records, get-record, get-alerts,
get-connectors, get-exclusion-rules) all carried an identical
permission_callback() returning current_user_can( 'view_stream' )
with the same rationale docblock. Move it into a shared trait so the
authorization rule lives in one place.

Each ability file require_once's the trait directly so per-test loaders
(which require ability files individually) keep working without any
autoloader changes.

Net -35 LOC. Single-site and multisite Ability suites unchanged: 316
tests pass with the same skipped/incomplete counts as before.
- orderby: add enum bound to Query::query()'s actual sortable columns,
  and change the default from 'date' (not a real Stream column;
  silently fell back to ID) to 'created'. This makes the silent
  fallback impossible at the schema layer for REST callers and
  surfaces the contract for direct PHP callers.
- user_id__in / connector__in: add maxItems: 100 so a caller cannot
  force an unbounded IN(...) clause from a single request.

Tests cover schema shape, REST schema validation (orderby=date now
rejected, 101 items rejected), and a behavioral regression that seeds
two records with out-of-order created/ID and asserts orderby=created
ASC actually orders by created -- not by ID, which is what the old
silent fallback was doing.
…or-context

Mirror the admin form's create-alert flow (classes/class-alerts.php:766-
806) so API-created alerts behave identically to UI-created ones:

- Validate alert_type against $plugin->alerts->alert_types (the
  registered notifier slugs). Schema can't enum these because
  wp_stream_alert_types is a filter -- a hardcoded enum would lock out
  3rd-party notifiers. Reject unknown slugs with
  stream_unknown_alert_type / status 400 BEFORE inserting the post.
- Split 'connector-context' input into trigger_connector +
  trigger_context meta keys, exactly like the admin form does. Without
  the split, Alert_Trigger_Context::check_record() silently let any
  connector through because trigger_connector was never populated --
  alerts created via the API were effectively connector-agnostic.
- Build an Alert model from the split meta and use $alert->get_title()
  for post_title, so the admin list shows a meaningful title instead of
  'Auto Draft'.

Tests cover the title regression, the connector-dash-context split, and
the alert_type rejection path (including no-side-effects: no post is
inserted when validation fails).
…ed multisite

On a network-activated multisite install, the Abilities API toggle is saved
to the wp_stream_network site option via Network::update_site_option().
However, Settings::get_options() only reads from get_site_option() when
is_network_admin() is true; in REST and frontend contexts $plugin->settings->options
reflects the (typically empty) per-site option. As a result, is_enabled()
returned false in REST even when the network admin had enabled the API,
making the entire Abilities API silently unreachable on network-activated
sites.

Read the network option directly via get_site_option($settings->network_options_key)
when is_multisite() && $plugin->is_network_activated(), and fall back to
the existing in-memory per-site options otherwise (preserves single-site
and per-site-activated behavior).

Adds two regression tests:
- test_is_enabled_reads_network_option_when_network_activated (@group ms-required)
  flips the wp_stream_is_network_activated filter and proves is_enabled()
  follows the network option even when in-memory options say disabled.
- test_is_enabled_reads_per_site_options_when_not_network_activated proves
  the network option is ignored when the plugin isn't network-activated.
Add //end try comments to satisfy Squiz.Commenting.LongConditionClosingComment
on the new is_enabled() multisite tests, and lift the inline 'not a
registered notifier' comment above the array literal so it doesn't trip
Squiz.Commenting.PostStatementComment in the unknown-alert_type test.
The comment claimed format:ip is a hint not enforced by
rest_validate_value_from_schema(), which is wrong. WP core's
rest_is_ip_address() in wp-includes/rest-api.php DOES validate the
format and rejects bogus IPs at the schema layer with
ability_invalid_input before our execute() runs.

Reframe the in-method check as defense-in-depth for direct PHP callers
who invoke $ability->execute() outside the REST stack.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Integrates Stream with the WordPress 6.9 Abilities API by introducing an ability base class + loader, registering 11 Stream operations under the stream/ namespace (gated behind an Advanced setting), and adding a comprehensive PHPUnit suite to validate schemas, permissions, and REST routing.

Changes:

  • Added Ability (abstract base) and Abilities (loader/gating + category registration) and wired the loader into Plugin::init().
  • Implemented multiple stream/* abilities (read/write/destructive) backed by existing Stream internals, plus shared view_stream permission handling for read-only abilities.
  • Added PHPUnit infrastructure and per-ability tests, updated coverage configuration, and extended PHPCS configuration for Stream’s custom capability.

Review Findings (issues to address)

1) Critical issues

  • stream/purge-records can delete other sites’ logs on multisite when Stream is not network-activated
    • Why it matters: Stream’s tables are shared (base prefix) and rely on blog_id for separation; without a blog_id constraint this endpoint can purge records across the network.
    • Suggested fix: Add a stream.blog_id = %d constraint when is_multisite() and $this->plugin->is_multisite_not_network_activated() are true (mirroring Admin::erase_stream_records() / scheduled purge behavior).
      if ( $this->plugin->is_multisite_not_network_activated() ) {
      	$where[] = 'stream.blog_id = %d'; $params[] = get_current_blog_id();
      }
    • WP VIP ref: Multisite scoping via get_current_blog_id(); safe SQL construction via $wpdb->prepare().

2) High issues

  • stream/get-alerts can return alert_meta in a shape that violates its declared output schema
    • Why it matters: When alert_meta is missing, get_post_meta(..., true) returns ''; casting to (array) yields a numerically-indexed array which JSON-encodes as a list, not an object, breaking schema expectations and clients.
    • Suggested fix: Normalize alert_meta to an associative array/object: only return the meta value if it’s already an array, otherwise return an empty array.
    • WP VIP ref: Data consistency for REST outputs; get_post_meta() return-type handling.

3) Medium/Low issues

  • test_orderby_created_actually_orders_by_created_not_id() doesn’t reliably detect an orderby fallback-to-ID regression

    • Why it matters: The test data currently makes created order align with insertion/ID order, so it can pass even if orderby=created is ignored.
    • Suggested fix: Insert records such that ID ordering conflicts with created ordering (e.g., insert the “newer timestamp” record first and the “older timestamp” record second) and assert against that conflict.
    • WP VIP ref: PHPUnit test correctness (ensuring assertions can fail for the intended regression).
  • Abilities API toggle description lists an endpoint shape that conflicts with this PR’s REST integration tests

    • Why it matters: The admin-facing setting description references /wp-abilities/v1/stream/*, while tests exercise /wp-abilities/v1/abilities/stream/{slug}/run; this can mislead admins during manual verification.
    • Suggested fix: Update the UI description text to match the actual route structure used by the Abilities API implementation being targeted.
    • WP VIP ref: Admin UI clarity / accurate operational documentation.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/phpunit/test-class-ability.php Unit tests for the abstract Ability base behavior (meta, permissions, registration).
tests/phpunit/test-class-abilities.php Unit tests for loader gating, multisite enablement behavior, and slug list.
tests/phpunit/fake-ability.php Concrete fake ability used by base-class tests.
tests/phpunit/abilities/test-rest-integration.php REST-level integration tests for routing/method enforcement and discovery.
tests/phpunit/abilities/test-class-ability-update-settings.php Tests for partial settings updates, key allowlisting, and memory refresh.
tests/phpunit/abilities/test-class-ability-purge-records.php Tests for destructive purge safety (confirm + filters) and cascade cleanup.
tests/phpunit/abilities/test-class-ability-get-settings.php Tests for settings read ability output and permissions.
tests/phpunit/abilities/test-class-ability-get-records.php Tests for records querying, schema allowlists/bounds, and ordering behavior.
tests/phpunit/abilities/test-class-ability-get-record.php Tests for single-record fetch behavior and not-found handling.
tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php Tests for exclusion-rule pivoting and internal-key stripping.
tests/phpunit/abilities/test-class-ability-get-connectors.php Tests for connector discovery output shape.
tests/phpunit/abilities/test-class-ability-get-alerts.php Tests for alert listing and status filtering.
tests/phpunit/abilities/test-class-ability-delete-alert.php Tests for deletion safety, idempotency, and post-type validation.
tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php Tests for rule append semantics, sanitization, and validation.
tests/phpunit/abilities/test-class-ability-create-alert.php Tests for alert creation, title generation, and alert-type validation.
tests/phpunit/abilities/abilities-testcase.php Shared Abilities test base (WP 6.9 gating, schema assertions, helpers).
tests/bootstrap.php Loads the new Abilities test base.
phpunit.xml Adds abilities/ directory to coverage include paths.
phpunit-multisite.xml Adds abilities/ directory to multisite coverage include paths.
phpcs.xml.dist Configures PHPCS to recognize view_stream as a custom capability.
classes/class-settings.php Adds the “Enable Abilities API” Advanced setting (WP 6.9+ only).
classes/class-plugin.php Wires Abilities loader into plugin initialization.
classes/class-ability.php Introduces the Ability abstract base and registration helper.
classes/class-abilities.php Introduces the Abilities loader (gating, category registration, instantiation).
abilities/trait-view-stream-permission.php Shared view_stream permission callback for read-only abilities.
abilities/class-ability-update-settings.php Implements stream/update-settings ability.
abilities/class-ability-purge-records.php Implements stream/purge-records ability (destructive).
abilities/class-ability-get-settings.php Implements stream/get-settings ability.
abilities/class-ability-get-records.php Implements stream/get-records ability with schema allowlists and paging.
abilities/class-ability-get-record.php Implements stream/get-record ability with multisite scoping guard.
abilities/class-ability-get-exclusion-rules.php Implements stream/get-exclusion-rules ability with pivoted output.
abilities/class-ability-get-connectors.php Implements stream/get-connectors ability (connector/context/action discovery).
abilities/class-ability-get-alerts.php Implements stream/get-alerts ability (status-filtered listing).
abilities/class-ability-delete-alert.php Implements stream/delete-alert ability (destructive + idempotent).
abilities/class-ability-create-exclusion-rule.php Implements stream/create-exclusion-rule ability with sanitization/validation.
abilities/class-ability-create-alert.php Implements stream/create-alert ability with alert-type validation and title generation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread abilities/class-ability-get-alerts.php Outdated
Comment thread tests/phpunit/abilities/test-class-ability-get-records.php Outdated
Comment thread classes/class-settings.php Outdated
…rby test, UI text

- purge-records: scope DELETE by blog_id on non-network-activated multisite
  to prevent cross-site record deletion; mirrors Admin::erase_stream_records().
- get-alerts: coerce missing alert_meta to {} instead of [""] so the response
  matches the declared object output schema.
- test_orderby_created_actually_orders_by_created_not_id: invert insertion
  order so ID-order conflicts with created-order; the test would now fail if
  the implementation silently fell back to ORDER BY ID.
- Settings UI: drop the specific /wp-abilities/v1/stream/* path from the
  toggle description (the actual route is owned by core's Abilities API).
- Add regression tests for the missing alert_meta normalization and for the
  per-blog purge isolation (multisite-only).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 5 comments.

Comment thread abilities/class-ability-get-record.php Outdated
Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread classes/class-settings.php
Comment thread tests/phpunit/test-class-abilities.php
…e, UTC cutoff, settings UX, hook isolation

- get-record / purge-records: scope by blog_id = get_current_blog_id() on
  any multisite request that is not is_network_admin() (REST is never
  network-admin). Replaces the previous is_multisite_not_network_activated()
  predicate, which left network-activated installs unprotected against a
  per-site admin reading or purging another site's records via REST.
  Mirrors Network::network_query_args() default scoping.
- purge-records: compute the older_than_days cutoff as a UTC DateTime in
  PHP and bind as %s, mirroring Admin::purge_scheduled_action(). The
  previous DATE_SUB(NOW(), INTERVAL %d DAY) used the MySQL server timezone
  while Stream's created column is UTC, so it could over- or under-purge
  on hosts where the server timezone is not UTC.
- Settings: hide the "Enable Abilities API" toggle on per-site settings
  screens when Stream is network-activated. The setting is read from the
  network option in that mode, so a per-site checkbox would have been a
  silent no-op.
- Tests: snapshot and restore $wp_filter['wp_abilities_api_init'] in
  Test_Abilities::setUp/tearDown so the existing remove_all_actions()
  calls inside individual tests don't pollute the global hook registry
  for subsequent tests in the same process.
- Tests: lock the new behaviors with regression coverage:
  * get-record returns stream_record_not_found for foreign-blog IDs on
    multisite (must not leak via guessing).
  * purge-records does not cross blog boundaries on multisite (rename
    drops the _when_not_network_activated suffix).
  * purge-records older_than_days uses a UTC cutoff: rows seeded with
    explicit UTC timestamps purge correctly regardless of server tz.
  * Settings field is visible on non-network-activated installs.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Comment on lines +174 to +198
// Delete stream rows first and capture rows_affected so the response reflects
// the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed
// non-empty here by the count( $where ) === 1 guard above. MySQL requires the
// "DELETE alias FROM tbl AS alias" form when the WHERE references an alias.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery
$delete_sql = "DELETE stream FROM {$wpdb->stream} AS stream WHERE {$where_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $wpdb->prepare( $delete_sql, $params ) );
$deleted = (int) $wpdb->rows_affected;

if ( 0 === $deleted ) {
return array( 'deleted' => 0 );
}

// Sweep orphaned meta rows whose parent record was just deleted. Idempotent
// and safe to run unconditionally; the LEFT JOIN scopes the cleanup to
// orphans across the whole streammeta table, which also catches any prior
// orphans without growing this query's blast radius beyond a single sweep.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
"DELETE meta FROM {$wpdb->streammeta} AS meta
LEFT JOIN {$wpdb->stream} AS stream ON stream.ID = meta.record_id
WHERE stream.ID IS NULL"
);

'properties' => array(
'deleted' => array(
'type' => 'integer',
'description' => 'Number of stream records deleted (meta rows are cascaded by record_id).',
Comment on lines +175 to +177
// the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed
// non-empty here by the count( $where ) === 1 guard above. MySQL requires the
// "DELETE alias FROM tbl AS alias" form when the WHERE references an alias.
Comment on lines +5 to +8
* Verifies that abilities registered through Abilities API actually serve HTTP
* requests at /wp-abilities/v1/abilities/stream/{slug}/run with correct status
* codes and method routing. Complements the per-ability unit tests, which only
* exercise execute() in isolation.
…atement

Replace the post-DELETE orphan sweep over the entire streammeta table with
a single multi-table DELETE that removes matching stream rows and their
meta in one statement, mirroring Admin::purge_scheduled_action(). Capture
the parent count up-front so the response still reports records-deleted
independent of how many meta rows were attached.

Also drop a stale comment that referenced a guard pattern no longer in
the code, and update the output-schema description so it matches the
implementation (no more 'cascade' wording, since there is no FK).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Comment on lines +118 to +121
// Refresh in-memory copy so subsequent abilities see the change.
$this->plugin->settings->options = $merged;

return $merged;
'properties' => array(
'settings' => array(
'type' => 'object',
'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are rejected; values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.',
Comment on lines +215 to +224
$options['exclude_rules'] = $rules;
update_option( $option_key, $options );

// Refresh in-memory copy.
$this->plugin->settings->options = $options;

return array(
'index' => $index,
'rule' => $rule,
);
return array(
'type' => 'object',
'additionalProperties' => true,
'description' => 'Settings keyed by {section}_{field} (e.g. general_records_ttl, advanced_enable_abilities_api).',
…hten schema descriptions

After update_option() the raw option array is sparse (omits defaults). Both
update-settings and create-exclusion-rule were assigning that sparse array
directly to $plugin->settings->options, leaving default-only keys missing
for any later code in the same request. Refresh the in-memory copy via
Settings::get_options() so defaults are merged in.

Also align two schema descriptions with actual behavior:

- update-settings now says unknown keys are *ignored* (the request only
  fails when no key matches a registered setting). This matches the
  array_intersect_key() filtering in execute().
- get-settings no longer promises advanced_enable_abilities_api in every
  response; that field is only registered on WP 6.9+ and, on
  network-activated multisite, only from network admin.
When a wp_stream_alerts post has no alert_meta postmeta row, the ability
was emitting an empty PHP array(), which JSON-encodes as [] and violates
the declared 'type: object' output schema.

Replace the empty array with a stdClass instance so wp_json_encode()
emits {} as the output schema requires. Also strengthen the regression
test to assert against the JSON-encoded payload (the previous PHP-level
array() comparison was passing despite the wire-format bug).
@PatelUtkarsh PatelUtkarsh requested a review from Copilot May 7, 2026 08:15
Squiz.Commenting.FunctionComment.WrongStyle was flagging the three
test methods that had only inline section comments above them, and
Generic.Commenting.DocComment.MissingShort was flagging the
@expectedIncorrectUsage-only docblock on test_unknown_ability_returns_404.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 6 comments.

Comment thread abilities/class-ability-get-records.php
Comment thread abilities/class-ability-update-settings.php
Comment thread abilities/class-ability-purge-records.php
Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread abilities/class-ability-delete-alert.php
Comment thread classes/class-abilities.php
…empotent category, doc fix

Four issues from the latest Copilot review on this branch:

- update-settings: PHP booleans on checkbox keys were silently coerced to ''
  by Settings::sanitize_setting_by_field_type() (it gates on is_numeric()).
  JSON clients naturally send true/false for checkbox-typed settings, so
  the round-trip would store '' instead of 0/1. Walk the registered fields
  to identify checkbox keys and normalize bools to 0/1 before sanitization.
  Add a regression test that round-trips both true and false.
- purge-records: the multi-table DELETE result was discarded, so a
  database-side failure (lock-wait timeout, deadlock, etc.) would still
  return the pre-counted 'deleted' as if the purge had succeeded. Check
  $wpdb->query()'s return value and surface a 500 WP_Error on false.
- class-abilities: register_category() now bails when the category is
  already registered, mirroring the idempotency pattern in
  register_abilities() and avoiding a core _doing_it_wrong notice when
  multiple loader instances exist (which the test harness already
  works around).
- get-records: the orderby description claimed unknown values fall back
  to ID in Query::query(), but the schema enum rejects them at REST
  validation. Tighten the description to match.
@PatelUtkarsh PatelUtkarsh marked this pull request as ready for review May 7, 2026 09:19
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.

2 participants