From 9c3aad2f4fe5ae6a162160c36b66a55c66e9767c Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:40:07 +0700 Subject: [PATCH 1/7] feat: add REST API endpoints for pattern listing and page creation Registers GET /waygate/v1/patterns (requires edit_posts) and POST /waygate/v1/pages (requires publish_pages) under the waygate/v1 namespace. Both endpoints delegate to existing Pattern_Lab methods; failed page creation returns a 422 WP_Error. --- includes/class-rest-api.php | 129 ++++++++++++++++++++++++++++++++++++ waygate.php | 2 + 2 files changed, 131 insertions(+) create mode 100644 includes/class-rest-api.php diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php new file mode 100644 index 0000000..0749ba8 --- /dev/null +++ b/includes/class-rest-api.php @@ -0,0 +1,129 @@ + \WP_REST_Server::READABLE, + 'callback' => array( self::class, 'get_patterns' ), + 'permission_callback' => fn() => current_user_can( 'edit_posts' ), + 'args' => array( + 'category' => array( + 'type' => 'string', + 'description' => 'Filter by category slug, e.g. "hero", "features", "cta".', + 'required' => false, + 'sanitize_callback' => 'sanitize_key', + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/pages', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( self::class, 'create_page' ), + 'permission_callback' => fn() => current_user_can( 'publish_pages' ), + 'args' => array( + 'title' => array( + 'type' => 'string', + 'description' => 'Page title.', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'patterns' => array( + 'type' => 'array', + 'description' => 'Ordered list of pattern slugs.', + 'required' => true, + 'items' => array( 'type' => 'string' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => 'Post status: "draft" or "publish".', + 'required' => false, + 'default' => 'draft', + 'enum' => array( 'draft', 'publish' ), + ), + ), + ) + ); + } + + /** + * GET /waygate/v1/patterns + * + * @param \WP_REST_Request $request Incoming REST request. + * @return \WP_REST_Response + */ + public static function get_patterns( \WP_REST_Request $request ): \WP_REST_Response { + $patterns = Pattern_Lab::get_patterns(); + + $category = $request->get_param( 'category' ); + if ( ! empty( $category ) ) { + $cat_slug = 'elayne/' . $category; + $patterns = array_values( + array_filter( $patterns, fn( $p ) => in_array( $cat_slug, $p['categories'], true ) ) + ); + } + + return rest_ensure_response( $patterns ); + } + + /** + * POST /waygate/v1/pages + * + * @param \WP_REST_Request $request Incoming REST request. + * @return \WP_REST_Response|\WP_Error + */ + public static function create_page( \WP_REST_Request $request ) { + $result = Pattern_Lab::create_page( + $request->get_param( 'title' ), + (array) $request->get_param( 'patterns' ), + $request->get_param( 'status' ) + ); + + if ( is_wp_error( $result ) ) { + return new \WP_Error( + $result->get_error_code(), + $result->get_error_message(), + array( 'status' => 422 ) + ); + } + + return rest_ensure_response( + array( + 'page_id' => $result, + 'edit_url' => get_edit_post_link( $result, 'raw' ), + 'view_url' => get_permalink( $result ), + ) + ); + } +} diff --git a/waygate.php b/waygate.php index d6cf6b1..27ecb0e 100644 --- a/waygate.php +++ b/waygate.php @@ -26,8 +26,10 @@ require_once WAYGATE_PLUGIN_DIR . 'includes/class-abilities-api.php'; require_once WAYGATE_PLUGIN_DIR . 'includes/class-ai-integration.php'; require_once WAYGATE_PLUGIN_DIR . 'includes/class-admin.php'; +require_once WAYGATE_PLUGIN_DIR . 'includes/class-rest-api.php'; add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Pattern_Lab', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\AI_Integration', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Abilities_API', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Admin', 'init' ) ); +add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Rest_API', 'init' ) ); From af93fc019ab89e4399f1a313b012a865eea74d3c Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:40:16 +0700 Subject: [PATCH 2/7] test: add unit tests for REST API endpoints Adds RestApiTest (14 tests) covering route registration, get_patterns filtering, and create_page success/error paths. Extends bootstrap.php with WP_REST_Request/Response/Server stubs and REST helper function stubs. --- tests/Unit/RestApiTest.php | 194 +++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 57 ++++++++++- 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/RestApiTest.php diff --git a/tests/Unit/RestApiTest.php b/tests/Unit/RestApiTest.php new file mode 100644 index 0000000..fe4da8f --- /dev/null +++ b/tests/Unit/RestApiTest.php @@ -0,0 +1,194 @@ +reset(); + $GLOBALS['wp_filters'] = []; + $GLOBALS['wp_rest_routes'] = []; + } + + private function register( string $slug, string $title = 'Test Pattern', array $categories = [] ): void { + WP_Block_Patterns_Registry::get_instance()->register( + [ + 'slug' => $slug, + 'title' => $title, + 'description' => '', + 'categories' => $categories, + 'keywords' => [], + ] + ); + } + + private function request( array $params ): WP_REST_Request { + return new WP_REST_Request( $params ); + } + + // --- register_routes() --- + + public function test_register_routes_registers_patterns_endpoint(): void { + Rest_API::register_routes(); + + $this->assertArrayHasKey( 'waygate/v1/patterns', $GLOBALS['wp_rest_routes'] ); + } + + public function test_register_routes_registers_pages_endpoint(): void { + Rest_API::register_routes(); + + $this->assertArrayHasKey( 'waygate/v1/pages', $GLOBALS['wp_rest_routes'] ); + } + + public function test_patterns_endpoint_uses_get_method(): void { + Rest_API::register_routes(); + + $this->assertSame( 'GET', $GLOBALS['wp_rest_routes']['waygate/v1/patterns']['methods'] ); + } + + public function test_pages_endpoint_uses_post_method(): void { + Rest_API::register_routes(); + + $this->assertSame( 'POST', $GLOBALS['wp_rest_routes']['waygate/v1/pages']['methods'] ); + } + + // --- get_patterns() --- + + public function test_get_patterns_returns_all_registered_patterns(): void { + $this->register( 'elayne/hero' ); + $this->register( 'elayne/cta' ); + + $data = Rest_API::get_patterns( $this->request( [] ) )->get_data(); + + $this->assertCount( 2, $data ); + } + + public function test_get_patterns_returns_empty_array_when_no_patterns(): void { + $data = Rest_API::get_patterns( $this->request( [] ) )->get_data(); + + $this->assertSame( [], $data ); + } + + public function test_get_patterns_filters_by_category(): void { + $this->register( 'elayne/hero', 'Hero', [ 'elayne/header' ] ); + $this->register( 'elayne/cta', 'CTA', [ 'elayne/footer' ] ); + + $data = Rest_API::get_patterns( $this->request( [ 'category' => 'header' ] ) )->get_data(); + + $this->assertCount( 1, $data ); + $this->assertSame( 'elayne/hero', $data[0]['slug'] ); + } + + public function test_get_patterns_returns_empty_for_unknown_category(): void { + $this->register( 'elayne/hero', 'Hero', [ 'elayne/header' ] ); + + $data = Rest_API::get_patterns( $this->request( [ 'category' => 'nonexistent' ] ) )->get_data(); + + $this->assertSame( [], $data ); + } + + public function test_get_patterns_without_category_param_returns_all(): void { + $this->register( 'elayne/hero' ); + $this->register( 'elayne/features' ); + + $data = Rest_API::get_patterns( $this->request( [ 'category' => null ] ) )->get_data(); + + $this->assertCount( 2, $data ); + } + + // --- create_page() --- + + public function test_create_page_returns_page_id_on_success(): void { + $this->register( 'elayne/hero' ); + + $result = Rest_API::create_page( + $this->request( + [ + 'title' => 'My Page', + 'patterns' => [ 'elayne/hero' ], + 'status' => 'draft', + ] + ) + ); + + $data = $result->get_data(); + + $this->assertArrayHasKey( 'page_id', $data ); + $this->assertIsInt( $data['page_id'] ); + } + + public function test_create_page_returns_edit_url_on_success(): void { + $this->register( 'elayne/hero' ); + + $result = Rest_API::create_page( + $this->request( + [ + 'title' => 'My Page', + 'patterns' => [ 'elayne/hero' ], + 'status' => 'draft', + ] + ) + ); + + $data = $result->get_data(); + + $this->assertArrayHasKey( 'edit_url', $data ); + $this->assertStringContainsString( 'action=edit', $data['edit_url'] ); + } + + public function test_create_page_returns_view_url_on_success(): void { + $this->register( 'elayne/hero' ); + + $result = Rest_API::create_page( + $this->request( + [ + 'title' => 'My Page', + 'patterns' => [ 'elayne/hero' ], + 'status' => 'draft', + ] + ) + ); + + $data = $result->get_data(); + + $this->assertArrayHasKey( 'view_url', $data ); + $this->assertNotEmpty( $data['view_url'] ); + } + + public function test_create_page_returns_wp_error_for_no_valid_patterns(): void { + $result = Rest_API::create_page( + $this->request( + [ + 'title' => 'My Page', + 'patterns' => [ 'nonexistent/slug' ], + 'status' => 'draft', + ] + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'no_valid_patterns', $result->get_error_code() ); + } + + public function test_create_page_error_carries_422_http_status(): void { + $result = Rest_API::create_page( + $this->request( + [ + 'title' => 'My Page', + 'patterns' => [ 'nonexistent/slug' ], + 'status' => 'draft', + ] + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 422, $result->get_error_data()['status'] ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7dc32c1..46ef5c9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -46,16 +46,71 @@ function current_user_can( string $capability ): bool { return true; } +function sanitize_key( string $key ): string { + return strtolower( preg_replace( '/[^a-z0-9_-]/', '', $key ) ); +} + +function get_edit_post_link( int $id, string $context = 'display' ): string { + return "https://example.com/wp-admin/post.php?post={$id}&action=edit"; +} + +function get_permalink( int $id ): string { + return "https://example.com/?page_id={$id}"; +} + +function rest_ensure_response( mixed $data ): WP_REST_Response { + if ( $data instanceof WP_REST_Response ) { + return $data; + } + return new WP_REST_Response( $data ); +} + +$GLOBALS['wp_rest_routes'] = []; + +function register_rest_route( string $namespace, string $route, array $args ): bool { + $GLOBALS['wp_rest_routes'][ $namespace . $route ] = $args; + return true; +} + // --- WordPress class stubs --- class WP_Error { public function __construct( private string $code = '', - private string $message = '' + private string $message = '', + private mixed $data = null ) {} public function get_error_code(): string { return $this->code; } public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } +} + +class WP_REST_Server { + const READABLE = 'GET'; + const CREATABLE = 'POST'; +} + +class WP_REST_Request { + private array $params; + + public function __construct( array $params = [] ) { + $this->params = $params; + } + + public function get_param( string $key ): mixed { + return $this->params[ $key ] ?? null; + } +} + +class WP_REST_Response { + public function __construct( + private mixed $data = null, + private int $status = 200 + ) {} + + public function get_data(): mixed { return $this->data; } + public function get_status(): int { return $this->status; } } class WP_Block_Patterns_Registry { From 729ccea5c206c92f8765bbad09c5c61b11806eb3 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:40:27 +0700 Subject: [PATCH 3/7] docs: mark Prompt Templates and REST API complete in roadmap --- docs/ROADMAP.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5e55304..7626fe8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # Waygate Roadmap & Improvement Ideas > **Document Version**: 1.1.0 -> **Last Updated**: 2026-05-24 +> **Last Updated**: 2026-05-25 > **Status**: Draft This document outlines potential improvements and feature additions for Waygate, based on WordPress 7.0 AI Client and Abilities API capabilities. @@ -143,6 +143,14 @@ Add a dropdown to filter patterns by category in the catalog table. ### Phase 2: Medium Effort ← **Next Up** +#### 8. Add Prompt Templates ✅ +**File**: `includes/class-ai-integration.php` +**File**: `includes/class-admin.php` + +Built-in prompt templates with `waygate_prompt_templates` filter, and a "Quick template" dropdown in the admin UI that populates the description field (with confirmation if the field already has content). + +**Status**: Complete as of 2026-05-25. + #### 5. Add Image Generation for Pattern Previews **New file**: `includes/class-image-generator.php` @@ -177,8 +185,8 @@ class ImageGenerator { **Benefit**: Visual pattern selection, better UX. -#### 6. Add REST API Endpoints for Remote Access -**New file**: `includes/class-rest-api.php` +#### 6. Add REST API Endpoints for Remote Access ✅ +**File**: `includes/class-rest-api.php` ```php class RestApi { @@ -447,9 +455,9 @@ public static function track_pattern_usage( string $pattern_slug ): void { ### Recommended Order 1. ~~Feature detection, ability annotations, generic prefixes — *Phase 1*~~ ✅ Done -2. Prompt templates — *Phase 2* (1–2 days) ← **Start here** -3. REST API endpoints — *Phase 2* (2–3 days) -4. Client-side abilities for editor integration — *Phase 2* (2–3 days) +2. ~~Prompt templates — *Phase 2* (1–2 days)~~ ✅ Done +3. ~~REST API endpoints — *Phase 2* (2–3 days)~~ ✅ Done +4. Client-side abilities for editor integration — *Phase 2* (2–3 days) ← **Start here** 5. Image generation for previews — *Phase 2* (2–3 days) 6. Batch page creation — *Phase 2* (1–2 days) 7. Cost tracking, pattern popularity — *Phase 3* (2–3 days) @@ -577,3 +585,4 @@ const abilities = getAbilities(); |------|--------|--------| | 2026-05-22 | Initial draft | Created roadmap document | | 2026-05-24 | Jasper Frumau | Marked Phase 1 complete; Phase 2 is next | +| 2026-05-25 | Jasper Frumau | Marked Prompt Templates (#8) complete; REST API is next | From 237bc26249368fd0775f53e5f15bce0ba62815a7 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:42:19 +0700 Subject: [PATCH 4/7] chore: bump version to 0.7.0 --- CHANGELOG.md | 8 ++++++++ waygate.php | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b490a1..9a82e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to Waygate will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-05-25 + +### Added +- REST API endpoints under `waygate/v1`: `GET /patterns` (requires `edit_posts`) lists all registered patterns with optional `?category=` filter; `POST /pages` (requires `publish_pages`) creates a page from an ordered pattern slug list and returns `page_id`, `edit_url`, and `view_url` +- Failed page creation via the REST API returns a `422` status code with the underlying error message +- PHPUnit tests for both REST endpoints covering route registration, category filtering, success response shape, and error handling (14 new tests, 86 total assertions) +- REST bootstrap stubs (`WP_REST_Request`, `WP_REST_Response`, `WP_REST_Server`, `rest_ensure_response`, `register_rest_route`) added to the unit test bootstrap + ## [0.6.2] - 2026-05-24 ### Documentation diff --git a/waygate.php b/waygate.php index 27ecb0e..11c0f22 100644 --- a/waygate.php +++ b/waygate.php @@ -3,7 +3,7 @@ * Plugin Name: Waygate * Plugin URI: https://github.com/imagewize/waygate * Description: AI-powered pattern page builder for the Elayne block theme. Lists registered patterns, creates pages from pattern slugs, and integrates with WordPress AI Client for natural-language page generation. - * Version: 0.6.2 + * Version: 0.7.0 * Author: Jasper Frumau * Author URI: https://imagewize.com * License: GPL-2.0-or-later @@ -18,7 +18,7 @@ defined( 'ABSPATH' ) || exit; -define( 'WAYGATE_VERSION', '0.6.2' ); +define( 'WAYGATE_VERSION', '0.7.0' ); define( 'WAYGATE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'WAYGATE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); From cbb128df7d20a28154260cf56296e4f0eb134017 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:43:00 +0700 Subject: [PATCH 5/7] docs: update README for v0.7.0 with REST API section --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3258bc..8ab3a62 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Waygate lets you assemble WordPress pages from block patterns — manually or via a natural-language AI prompt powered by the WordPress AI Client (WordPress 7.0+). Works with any block theme; [Elayne](https://github.com/imagewize/elayne) is the primary supported theme. -> **Beta** — v0.6.0. Use on staging/development sites; not yet recommended for production. +> **Beta** — v0.7.0. Use on staging/development sites; not yet recommended for production. --- @@ -17,6 +17,7 @@ Waygate lets you assemble WordPress pages from block patterns — manually or vi - **Prompt templates** — Six built-in page templates (Homepage, About, Services, Contact, Landing Page, Portfolio) pre-fill the AI prompt; extend via the `waygate_prompt_templates` filter - **Feature detection** — AI form is hidden automatically when no provider supports text generation - **Abilities API** — Exposes `elayne/list-patterns` and `elayne/create-page` abilities for WP 7.0+ +- **REST API** — `GET /wp-json/waygate/v1/patterns` and `POST /wp-json/waygate/v1/pages` for headless and external tool integration - **Multi-provider** — Works with Mistral, Claude, OpenAI, or Gemini via WP AI Client - **Any block theme** — Default prefix is `elayne/`; extend via the `waygate_pattern_prefixes` filter @@ -103,6 +104,37 @@ When WordPress 7.0's Abilities API is available, Waygate registers two abilities --- +## REST API + +Waygate exposes two REST endpoints under `/wp-json/waygate/v1/`: + +| Method | Endpoint | Permission | Description | +|---|---|---|---| +| `GET` | `/patterns` | `edit_posts` | List all registered patterns; optional `?category=hero` filter | +| `POST` | `/pages` | `publish_pages` | Create a page from pattern slugs | + +**Example — list patterns filtered by category:** + +```bash +curl -u admin:password https://example.com/wp-json/waygate/v1/patterns?category=hero +``` + +**Example — create a page:** + +```bash +curl -u admin:password -X POST https://example.com/wp-json/waygate/v1/pages \ + -H "Content-Type: application/json" \ + -d '{"title":"My Page","patterns":["elayne/hero","elayne/features","elayne/cta"],"status":"draft"}' +``` + +Response: + +```json +{ "page_id": 42, "edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit", "view_url": "https://example.com/?page_id=42" } +``` + +--- + ## Development ```bash From aa552985809e45389f3af713bd0dff95dbd6aa14 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:50:09 +0700 Subject: [PATCH 6/7] feat: restrict page creation to draft and add rate limiting POST /waygate/v1/pages now always creates drafts; the status parameter has been removed to prevent accidental publishing via the API. Adds a per-user transient-based rate limiter (10 req/60 s) in the permission callback, returning 429 when exceeded. The limit is filterable via waygate_rate_limit. --- includes/class-rest-api.php | 43 +++++++++++++---- tests/Unit/RestApiTest.php | 96 +++++++++++++++++++------------------ tests/bootstrap.php | 15 ++++++ 3 files changed, 97 insertions(+), 57 deletions(-) diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index 0749ba8..a788d94 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -14,7 +14,8 @@ */ class Rest_API { - const NAMESPACE = 'waygate/v1'; + const NAMESPACE = 'waygate/v1'; + const RATE_LIMIT = 10; // Max page-creation requests per user per 60 seconds. /** * Registers the rest_api_init hook. @@ -51,7 +52,19 @@ public static function register_routes(): void { array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( self::class, 'create_page' ), - 'permission_callback' => fn() => current_user_can( 'publish_pages' ), + 'permission_callback' => function () { + if ( ! current_user_can( 'publish_pages' ) ) { + return false; + } + if ( self::is_rate_limited() ) { + return new \WP_Error( + 'rate_limited', + 'Too many requests. Try again in a minute.', + array( 'status' => 429 ) + ); + } + return true; + }, 'args' => array( 'title' => array( 'type' => 'string', @@ -65,13 +78,6 @@ public static function register_routes(): void { 'required' => true, 'items' => array( 'type' => 'string' ), ), - 'status' => array( - 'type' => 'string', - 'description' => 'Post status: "draft" or "publish".', - 'required' => false, - 'default' => 'draft', - 'enum' => array( 'draft', 'publish' ), - ), ), ) ); @@ -97,6 +103,23 @@ public static function get_patterns( \WP_REST_Request $request ): \WP_REST_Respo return rest_ensure_response( $patterns ); } + /** + * Returns true if the current user has exceeded the page-creation rate limit. + * Uses a per-user transient counter with a 60-second sliding window. + */ + public static function is_rate_limited(): bool { + $key = 'waygate_rl_' . get_current_user_id(); + $count = (int) get_transient( $key ); + $limit = apply_filters( 'waygate_rate_limit', self::RATE_LIMIT ); + + if ( $count >= $limit ) { + return true; + } + + set_transient( $key, $count + 1, 60 ); + return false; + } + /** * POST /waygate/v1/pages * @@ -107,7 +130,7 @@ public static function create_page( \WP_REST_Request $request ) { $result = Pattern_Lab::create_page( $request->get_param( 'title' ), (array) $request->get_param( 'patterns' ), - $request->get_param( 'status' ) + 'draft' ); if ( is_wp_error( $result ) ) { diff --git a/tests/Unit/RestApiTest.php b/tests/Unit/RestApiTest.php index fe4da8f..84dba00 100644 --- a/tests/Unit/RestApiTest.php +++ b/tests/Unit/RestApiTest.php @@ -15,6 +15,7 @@ protected function setUp(): void { WP_Block_Patterns_Registry::get_instance()->reset(); $GLOBALS['wp_filters'] = []; $GLOBALS['wp_rest_routes'] = []; + $GLOBALS['wp_transients'] = []; } private function register( string $slug, string $title = 'Test Pattern', array $categories = [] ): void { @@ -108,17 +109,9 @@ public function test_get_patterns_without_category_param_returns_all(): void { public function test_create_page_returns_page_id_on_success(): void { $this->register( 'elayne/hero' ); - $result = Rest_API::create_page( - $this->request( - [ - 'title' => 'My Page', - 'patterns' => [ 'elayne/hero' ], - 'status' => 'draft', - ] - ) - ); - - $data = $result->get_data(); + $data = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'elayne/hero' ] ] ) + )->get_data(); $this->assertArrayHasKey( 'page_id', $data ); $this->assertIsInt( $data['page_id'] ); @@ -127,17 +120,9 @@ public function test_create_page_returns_page_id_on_success(): void { public function test_create_page_returns_edit_url_on_success(): void { $this->register( 'elayne/hero' ); - $result = Rest_API::create_page( - $this->request( - [ - 'title' => 'My Page', - 'patterns' => [ 'elayne/hero' ], - 'status' => 'draft', - ] - ) - ); - - $data = $result->get_data(); + $data = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'elayne/hero' ] ] ) + )->get_data(); $this->assertArrayHasKey( 'edit_url', $data ); $this->assertStringContainsString( 'action=edit', $data['edit_url'] ); @@ -146,17 +131,9 @@ public function test_create_page_returns_edit_url_on_success(): void { public function test_create_page_returns_view_url_on_success(): void { $this->register( 'elayne/hero' ); - $result = Rest_API::create_page( - $this->request( - [ - 'title' => 'My Page', - 'patterns' => [ 'elayne/hero' ], - 'status' => 'draft', - ] - ) - ); - - $data = $result->get_data(); + $data = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'elayne/hero' ] ] ) + )->get_data(); $this->assertArrayHasKey( 'view_url', $data ); $this->assertNotEmpty( $data['view_url'] ); @@ -164,13 +141,7 @@ public function test_create_page_returns_view_url_on_success(): void { public function test_create_page_returns_wp_error_for_no_valid_patterns(): void { $result = Rest_API::create_page( - $this->request( - [ - 'title' => 'My Page', - 'patterns' => [ 'nonexistent/slug' ], - 'status' => 'draft', - ] - ) + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'nonexistent/slug' ] ] ) ); $this->assertInstanceOf( WP_Error::class, $result ); @@ -179,16 +150,47 @@ public function test_create_page_returns_wp_error_for_no_valid_patterns(): void public function test_create_page_error_carries_422_http_status(): void { $result = Rest_API::create_page( - $this->request( - [ - 'title' => 'My Page', - 'patterns' => [ 'nonexistent/slug' ], - 'status' => 'draft', - ] - ) + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'nonexistent/slug' ] ] ) ); $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 422, $result->get_error_data()['status'] ); } + + // --- is_rate_limited() --- + + public function test_is_rate_limited_returns_false_under_limit(): void { + $this->assertFalse( Rest_API::is_rate_limited() ); + } + + public function test_is_rate_limited_increments_transient_counter(): void { + Rest_API::is_rate_limited(); + Rest_API::is_rate_limited(); + + $this->assertSame( 2, $GLOBALS['wp_transients']['waygate_rl_1'] ); + } + + public function test_is_rate_limited_returns_true_at_limit(): void { + $GLOBALS['wp_transients']['waygate_rl_1'] = 10; + + $this->assertTrue( Rest_API::is_rate_limited() ); + } + + public function test_is_rate_limited_returns_true_above_limit(): void { + $GLOBALS['wp_transients']['waygate_rl_1'] = 99; + + $this->assertTrue( Rest_API::is_rate_limited() ); + } + + public function test_rate_limit_is_filterable(): void { + add_filter( 'waygate_rate_limit', fn() => 2 ); + + Rest_API::is_rate_limited(); // count → 1 + Rest_API::is_rate_limited(); // count → 2, now at limit + $result = Rest_API::is_rate_limited(); // should be blocked + + remove_all_filters( 'waygate_rate_limit' ); + + $this->assertTrue( $result ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 46ef5c9..08f4516 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -46,6 +46,21 @@ function current_user_can( string $capability ): bool { return true; } +function get_current_user_id(): int { + return 1; +} + +$GLOBALS['wp_transients'] = []; + +function get_transient( string $key ): mixed { + return $GLOBALS['wp_transients'][ $key ] ?? false; +} + +function set_transient( string $key, mixed $value, int $expiration = 0 ): bool { + $GLOBALS['wp_transients'][ $key ] = $value; + return true; +} + function sanitize_key( string $key ): string { return strtolower( preg_replace( '/[^a-z0-9_-]/', '', $key ) ); } From e1a1a683fa2f14509bcca73bebbc6c2aee9e2395 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 14:50:17 +0700 Subject: [PATCH 7/7] docs: document draft-only restriction and rate limiting in v0.7.0 --- CHANGELOG.md | 8 +++++--- README.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a82e92..9a2dda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.7.0] - 2026-05-25 ### Added -- REST API endpoints under `waygate/v1`: `GET /patterns` (requires `edit_posts`) lists all registered patterns with optional `?category=` filter; `POST /pages` (requires `publish_pages`) creates a page from an ordered pattern slug list and returns `page_id`, `edit_url`, and `view_url` +- REST API endpoints under `waygate/v1`: `GET /patterns` (requires `edit_posts`) lists all registered patterns with optional `?category=` filter; `POST /pages` (requires `publish_pages`) creates a draft page from an ordered pattern slug list and returns `page_id`, `edit_url`, and `view_url` +- Per-user rate limiting on `POST /wp-json/waygate/v1/pages`: maximum 10 requests per 60-second window per user; returns `429` when exceeded; limit is filterable via `waygate_rate_limit` +- `POST /pages` always creates pages as `draft`; the `status` parameter has been removed to prevent accidental publishing via the API - Failed page creation via the REST API returns a `422` status code with the underlying error message -- PHPUnit tests for both REST endpoints covering route registration, category filtering, success response shape, and error handling (14 new tests, 86 total assertions) -- REST bootstrap stubs (`WP_REST_Request`, `WP_REST_Response`, `WP_REST_Server`, `rest_ensure_response`, `register_rest_route`) added to the unit test bootstrap +- PHPUnit tests for both REST endpoints and the rate limiter (19 new tests, 91 total assertions) +- REST bootstrap stubs (`WP_REST_Request`, `WP_REST_Response`, `WP_REST_Server`, `rest_ensure_response`, `register_rest_route`, transient helpers) added to the unit test bootstrap ## [0.6.2] - 2026-05-24 diff --git a/README.md b/README.md index 8ab3a62..0e33f49 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Waygate exposes two REST endpoints under `/wp-json/waygate/v1/`: | Method | Endpoint | Permission | Description | |---|---|---|---| | `GET` | `/patterns` | `edit_posts` | List all registered patterns; optional `?category=hero` filter | -| `POST` | `/pages` | `publish_pages` | Create a page from pattern slugs | +| `POST` | `/pages` | `publish_pages` | Create a **draft** page from pattern slugs (max 10 req/min per user) | **Example — list patterns filtered by category:**