From d755449dd4db967261eaba1bf31be396f481d18f Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 1 Aug 2026 23:24:53 -0400 Subject: [PATCH] feat(kb): functional knowledge base backed by database tables Port the Laravel reference knowledge base to the WordPress plugin's table-based convention, replacing the stub that queried a never-registered `escalated_article` custom post type (so the widget always returned nothing). - Add `escalated_article_categories` (self-referencing tree, slug/position) and `escalated_articles` (draft/published + published_at, unique slug, optional category/author, view/helpful counters) tables to the activator's create_tables(); existing installs pick them up via maybe_upgrade() on the version bump to 1.3.0. - Add Article and ArticleCategory models using the static $wpdb helper pattern. - Add admin CRUD REST controllers under /admin/kb/articles and /admin/kb/categories, gated by the existing kb.* capabilities (no new capability, so the activator capability-count assertion is unchanged). - Wire the public widget endpoints to read published articles from the tables: list, show (increments view count, returns related articles), and a new /articles/{slug}/feedback endpoint recording helpful/not-helpful when enabled. Adds Test_Knowledge_Base_Api (13 tests) covering table creation, admin create -> public list/show, unpublished excluded, category assignment, capability gating, view increment, and feedback. Full suite: 391 passing. --- CHANGELOG.md | 1 + escalated.php | 4 +- includes/Api/class-api-bootstrap.php | 2 + .../Api/class-article-category-controller.php | 247 ++++++++++++ includes/Api/class-article-controller.php | 354 +++++++++++++++++ includes/Api/class-widget-controller.php | 135 +++++-- includes/Models/Article.php | 331 ++++++++++++++++ includes/Models/ArticleCategory.php | 205 ++++++++++ includes/class-activator.php | 43 +++ tests/Test_Knowledge_Base_Api.php | 365 ++++++++++++++++++ 10 files changed, 1654 insertions(+), 33 deletions(-) create mode 100644 includes/Api/class-article-category-controller.php create mode 100644 includes/Api/class-article-controller.php create mode 100644 includes/Models/Article.php create mode 100644 includes/Models/ArticleCategory.php create mode 100644 tests/Test_Knowledge_Base_Api.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d9e78..68c04d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. - Workflow `delay` action — pauses a workflow run for N seconds and resumes the remaining actions via a per-minute WP-Cron sweep. Backed by a new `escalated_deferred_workflow_jobs` table with a composite `(status, run_at)` index for efficient polling. Existing installs need to reactivate the plugin to pick up the new table. - Users management admin page (Escalated → Users) — list WordPress users with their Escalated admin/agent roles, search by name/email, paginated 20 per page. Toggle the `escalated_admin` / `escalated_agent` WP roles per user with the same self-demote and admin→agent cascade rules as the Laravel reference (escalated-laravel #94). Gated by the `escalated_user_manage` capability (held by `escalated_admin` and `administrator` roles). - Two-factor authentication (TOTP + recovery codes), porting the Laravel reference. RFC 6238 TOTP implemented in pure PHP (`hash_hmac('sha1', ...)`, base32-decoded secret) with no external dependency. New `escalated_two_factors` table (one row per user) stores the AES-256-CBC-encrypted secret, a JSON array of SHA-256-hashed single-use recovery codes, and `confirmed_at`. Self-service REST routes under `escalated/v1/admin/two-factor` — status, setup (secret + otpauth URI + recovery codes), confirm, verify (TOTP or recovery code challenge), regenerate recovery codes, and disable — each acting on the authenticating token's user. Existing installs need to reactivate the plugin to pick up the new table. +- Functional knowledge base, porting the Laravel reference. Two new tables — `escalated_article_categories` (self-referencing category tree with slug/position) and `escalated_articles` (draft/published status with `published_at`, unique slug, optional category + author, and view/helpful counters) — replace the never-registered `escalated_article` custom post type the widget previously queried (it always returned nothing). Admin CRUD REST routes under `escalated/v1/admin/kb/articles` and `escalated/v1/admin/kb/categories`, gated by the existing `escalated_kb_view`/`_create`/`_edit`/`_delete` capabilities (no new capability added). Public widget endpoints (`/widget/articles`, `/widget/articles/{slug}`, and a new `/widget/articles/{slug}/feedback`) now read published articles from these tables, increment the view counter, return related articles, and record helpful/not-helpful feedback when enabled. `Activator::maybe_upgrade()` creates the tables on version bump — existing installs pick them up automatically on upgrade. ### Changed - License changed from GPL-2.0-or-later to MIT. diff --git a/escalated.php b/escalated.php index 196dda1..c778060 100644 --- a/escalated.php +++ b/escalated.php @@ -4,7 +4,7 @@ * Plugin Name: Escalated * Plugin URI: https://github.com/escalated-dev/escalated-wordpress * Description: A full-featured helpdesk and ticketing system with multi-role support, SLA tracking, escalation rules, inbound email, macros, and REST API. - * Version: 1.2.1 + * Version: 1.3.0 * Author: Escalated * Author URI: https://escalated.dev * License: MIT @@ -18,7 +18,7 @@ exit; } -define('ESCALATED_VERSION', '1.2.1'); +define('ESCALATED_VERSION', '1.3.0'); define('ESCALATED_PLUGIN_FILE', __FILE__); define('ESCALATED_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('ESCALATED_PLUGIN_URL', plugin_dir_url(__FILE__)); diff --git a/includes/Api/class-api-bootstrap.php b/includes/Api/class-api-bootstrap.php index 934369a..1d59f9e 100644 --- a/includes/Api/class-api-bootstrap.php +++ b/includes/Api/class-api-bootstrap.php @@ -35,6 +35,8 @@ public function register_routes(): void new Two_Factor_Controller, new Events_Controller, new Widget_Controller, + new Article_Controller, + new Article_Category_Controller, new Saved_View_Controller, new Ticket_Snooze_Controller, new Ticket_Split_Controller, diff --git a/includes/Api/class-article-category-controller.php b/includes/Api/class-article-category-controller.php new file mode 100644 index 0000000..80d37f3 --- /dev/null +++ b/includes/Api/class-article-category-controller.php @@ -0,0 +1,247 @@ +namespace; + + register_rest_route($ns, '/admin/kb/categories', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'index'], + 'permission_callback' => [$this, 'permission_view'], + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'store'], + 'permission_callback' => [$this, 'permission_edit'], + ], + ]); + + register_rest_route($ns, '/admin/kb/categories/(?P\d+)', [ + [ + 'methods' => ['PUT', 'PATCH'], + 'callback' => [$this, 'update'], + 'permission_callback' => [$this, 'permission_edit'], + 'args' => ['id' => ['required' => true, 'type' => 'integer']], + ], + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [$this, 'destroy'], + 'permission_callback' => [$this, 'permission_edit'], + 'args' => ['id' => ['required' => true, 'type' => 'integer']], + ], + ]); + } + + /** + * @return bool|WP_Error + */ + public function permission_view() + { + return $this->require_cap('escalated_kb_view'); + } + + /** + * @return bool|WP_Error + */ + public function permission_edit() + { + return $this->require_cap('escalated_kb_edit'); + } + + /** + * @return bool|WP_Error + */ + private function require_cap(string $cap) + { + if (! is_user_logged_in()) { + return new WP_Error( + 'escalated_unauthorized', + __('You must be logged in.', 'escalated'), + ['status' => 401] + ); + } + + if (! current_user_can($cap)) { + return new WP_Error( + 'escalated_forbidden', + __('You do not have permission to manage knowledge base categories.', 'escalated'), + ['status' => 403] + ); + } + + return true; + } + + /** + * GET /admin/kb/categories + */ + public function index(WP_REST_Request $request) + { + unset($request); + + $categories = []; + foreach (ArticleCategory::all_with_article_counts() as $row) { + $categories[] = $this->format_category($row); + } + + return $this->success(['categories' => $categories]); + } + + /** + * POST /admin/kb/categories + */ + public function store(WP_REST_Request $request) + { + $data = $this->parse_json_body($request); + + $validated = $this->validate($data); + if (is_wp_error($validated)) { + return $validated; + } + + $slug_base = sanitize_title($validated['slug'] !== '' ? $validated['slug'] : $validated['name']); + $validated['slug'] = ArticleCategory::unique_slug($slug_base); + + $id = ArticleCategory::create($validated); + if ($id === false) { + return $this->error('escalated_create_failed', __('Failed to create category.', 'escalated'), 500); + } + + return $this->success(['id' => (int) $id, 'category' => $this->format_category(ArticleCategory::find($id))], 201); + } + + /** + * PUT/PATCH /admin/kb/categories/{id} + */ + public function update(WP_REST_Request $request) + { + $id = (int) $request->get_param('id'); + if (! ArticleCategory::find($id)) { + return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404); + } + + $data = $this->parse_json_body($request); + + $validated = $this->validate($data, $id); + if (is_wp_error($validated)) { + return $validated; + } + + $slug_base = sanitize_title($validated['slug'] !== '' ? $validated['slug'] : $validated['name']); + $validated['slug'] = ArticleCategory::unique_slug($slug_base, $id); + + $ok = ArticleCategory::update($id, $validated); + if ($ok === false) { + return $this->error('escalated_update_failed', __('Failed to update category.', 'escalated'), 500); + } + + return $this->success(['category' => $this->format_category(ArticleCategory::find($id))]); + } + + /** + * DELETE /admin/kb/categories/{id} + */ + public function destroy(WP_REST_Request $request) + { + $id = (int) $request->get_param('id'); + if (! ArticleCategory::find($id)) { + return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404); + } + + ArticleCategory::delete($id); + + return $this->success(null, 204); + } + + /** + * Validate + normalise a category payload. + * + * @param array $data + * @param int|null $self_id The row being updated (excluded from parent checks). + * @return array|WP_Error + */ + private function validate(array $data, $self_id = null) + { + $name = isset($data['name']) ? trim(sanitize_text_field((string) $data['name'])) : ''; + if ($name === '') { + return $this->error('escalated_invalid', __('A name is required.', 'escalated'), 422); + } + + $parent_id = null; + if (isset($data['parent_id']) && $data['parent_id'] !== '' && $data['parent_id'] !== null) { + $parent_id = (int) $data['parent_id']; + if ($self_id !== null && $parent_id === (int) $self_id) { + return $this->error('escalated_invalid', __('A category cannot be its own parent.', 'escalated'), 422); + } + if (! ArticleCategory::find($parent_id)) { + return $this->error('escalated_invalid', __('The selected parent category does not exist.', 'escalated'), 422); + } + } + + return [ + 'name' => $name, + 'slug' => isset($data['slug']) ? (string) $data['slug'] : '', + 'parent_id' => $parent_id, + 'position' => isset($data['position']) ? max(0, (int) $data['position']) : 0, + 'description' => isset($data['description']) ? sanitize_textarea_field((string) $data['description']) : null, + ]; + } + + /** + * @return array + */ + private function format_category($row): array + { + return [ + 'id' => (int) $row->id, + 'name' => $row->name, + 'slug' => $row->slug, + 'parent_id' => $row->parent_id !== null ? (int) $row->parent_id : null, + 'position' => (int) $row->position, + 'description' => $row->description, + 'articles_count' => isset($row->articles_count) ? (int) $row->articles_count : 0, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]; + } + + /** + * @return array + */ + private function parse_json_body(WP_REST_Request $request): array + { + $json = $request->get_json_params(); + if (is_array($json)) { + return $json; + } + + $body = $request->get_body_params(); + + return is_array($body) ? $body : []; + } +} diff --git a/includes/Api/class-article-controller.php b/includes/Api/class-article-controller.php new file mode 100644 index 0000000..f7804fe --- /dev/null +++ b/includes/Api/class-article-controller.php @@ -0,0 +1,354 @@ +namespace; + + register_rest_route($ns, '/admin/kb/articles', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'index'], + 'permission_callback' => [$this, 'permission_view'], + 'args' => [ + 'search' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + 'status' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + 'category_id' => ['type' => 'integer'], + ], + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'store'], + 'permission_callback' => [$this, 'permission_create'], + ], + ]); + + register_rest_route($ns, '/admin/kb/articles/(?P\d+)', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'show'], + 'permission_callback' => [$this, 'permission_view'], + 'args' => ['id' => ['required' => true, 'type' => 'integer']], + ], + [ + 'methods' => ['PUT', 'PATCH'], + 'callback' => [$this, 'update'], + 'permission_callback' => [$this, 'permission_edit'], + 'args' => ['id' => ['required' => true, 'type' => 'integer']], + ], + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [$this, 'destroy'], + 'permission_callback' => [$this, 'permission_delete'], + 'args' => ['id' => ['required' => true, 'type' => 'integer']], + ], + ]); + } + + // --------------------------------------------------------------------- + // Permission callbacks (existing kb.* capabilities — no new caps added) + // --------------------------------------------------------------------- + + /** + * @return bool|WP_Error + */ + public function permission_view() + { + return $this->require_cap('escalated_kb_view'); + } + + /** + * @return bool|WP_Error + */ + public function permission_create() + { + return $this->require_cap('escalated_kb_create'); + } + + /** + * @return bool|WP_Error + */ + public function permission_edit() + { + return $this->require_cap('escalated_kb_edit'); + } + + /** + * @return bool|WP_Error + */ + public function permission_delete() + { + return $this->require_cap('escalated_kb_delete'); + } + + /** + * Shared login + capability guard. + * + * @return bool|WP_Error + */ + private function require_cap(string $cap) + { + if (! is_user_logged_in()) { + return new WP_Error( + 'escalated_unauthorized', + __('You must be logged in.', 'escalated'), + ['status' => 401] + ); + } + + if (! current_user_can($cap)) { + return new WP_Error( + 'escalated_forbidden', + __('You do not have permission to manage knowledge base articles.', 'escalated'), + ['status' => 403] + ); + } + + return true; + } + + // --------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------- + + /** + * GET /admin/kb/articles + */ + public function index(WP_REST_Request $request) + { + $filters = [ + 'search' => (string) $request->get_param('search'), + 'status' => (string) $request->get_param('status'), + 'category_id' => $request->get_param('category_id'), + ]; + + $articles = array_map([$this, 'format_article'], Article::all($filters)); + + return $this->success([ + 'articles' => $articles, + 'categories' => $this->format_categories(ArticleCategory::all()), + 'filters' => [ + 'search' => $filters['search'], + 'status' => $filters['status'], + 'category_id' => $filters['category_id'] !== null ? (int) $filters['category_id'] : null, + ], + ]); + } + + /** + * GET /admin/kb/articles/{id} + */ + public function show(WP_REST_Request $request) + { + $article = Article::find((int) $request->get_param('id')); + if (! $article) { + return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); + } + + return $this->success([ + 'article' => $this->format_article($article), + 'categories' => $this->format_categories(ArticleCategory::all()), + ]); + } + + /** + * POST /admin/kb/articles + */ + public function store(WP_REST_Request $request) + { + $data = $this->parse_json_body($request); + + $validated = $this->validate($data); + if (is_wp_error($validated)) { + return $validated; + } + + $slug_base = sanitize_title($validated['slug'] !== '' ? $validated['slug'] : $validated['title']); + $validated['slug'] = Article::unique_slug($slug_base); + $validated['author_id'] = get_current_user_id() ?: null; + + if ($validated['status'] === 'published') { + $validated['published_at'] = current_time('mysql'); + } + + $id = Article::create($validated); + if ($id === false) { + return $this->error('escalated_create_failed', __('Failed to create article.', 'escalated'), 500); + } + + return $this->success(['id' => (int) $id, 'article' => $this->format_article(Article::find($id))], 201); + } + + /** + * PUT/PATCH /admin/kb/articles/{id} + */ + public function update(WP_REST_Request $request) + { + $id = (int) $request->get_param('id'); + $article = Article::find($id); + if (! $article) { + return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); + } + + $data = $this->parse_json_body($request); + + $validated = $this->validate($data); + if (is_wp_error($validated)) { + return $validated; + } + + $slug_base = sanitize_title($validated['slug'] !== '' ? $validated['slug'] : $validated['title']); + $validated['slug'] = Article::unique_slug($slug_base, $id); + + // Stamp published_at the first time an article becomes published. + if ($validated['status'] === 'published' && empty($article->published_at)) { + $validated['published_at'] = current_time('mysql'); + } + + $ok = Article::update($id, $validated); + if ($ok === false) { + return $this->error('escalated_update_failed', __('Failed to update article.', 'escalated'), 500); + } + + return $this->success(['article' => $this->format_article(Article::find($id))]); + } + + /** + * DELETE /admin/kb/articles/{id} + */ + public function destroy(WP_REST_Request $request) + { + $id = (int) $request->get_param('id'); + if (! Article::find($id)) { + return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); + } + + Article::delete($id); + + return $this->success(null, 204); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /** + * Validate + normalise an article payload. + * + * @param array $data + * @return array|WP_Error + */ + private function validate(array $data) + { + $title = isset($data['title']) ? trim(sanitize_text_field((string) $data['title'])) : ''; + if ($title === '') { + return $this->error('escalated_invalid', __('A title is required.', 'escalated'), 422); + } + + $status = isset($data['status']) ? (string) $data['status'] : 'draft'; + if (! in_array($status, ['draft', 'published'], true)) { + return $this->error('escalated_invalid', __('Status must be draft or published.', 'escalated'), 422); + } + + $category_id = null; + if (isset($data['category_id']) && $data['category_id'] !== '' && $data['category_id'] !== null) { + $category_id = (int) $data['category_id']; + if (! ArticleCategory::find($category_id)) { + return $this->error('escalated_invalid', __('The selected category does not exist.', 'escalated'), 422); + } + } + + return [ + 'title' => $title, + 'slug' => isset($data['slug']) ? (string) $data['slug'] : '', + 'body' => isset($data['body']) ? wp_kses_post((string) $data['body']) : null, + 'status' => $status, + 'category_id' => $category_id, + ]; + } + + /** + * @return array + */ + private function format_article($row): array + { + $category = null; + if (! empty($row->category_id)) { + $cat = ArticleCategory::find((int) $row->category_id); + if ($cat) { + $category = ['id' => (int) $cat->id, 'name' => $cat->name, 'slug' => $cat->slug]; + } + } + + return [ + 'id' => (int) $row->id, + 'category_id' => $row->category_id !== null ? (int) $row->category_id : null, + 'category' => $category, + 'title' => $row->title, + 'slug' => $row->slug, + 'body' => $row->body, + 'status' => $row->status, + 'author_id' => $row->author_id !== null ? (int) $row->author_id : null, + 'view_count' => (int) $row->view_count, + 'helpful_count' => (int) $row->helpful_count, + 'not_helpful_count' => (int) $row->not_helpful_count, + 'published_at' => $row->published_at, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]; + } + + /** + * @param array $rows + * @return array> + */ + private function format_categories(array $rows): array + { + $out = []; + foreach ($rows as $row) { + $out[] = ['id' => (int) $row->id, 'name' => $row->name, 'slug' => $row->slug]; + } + + return $out; + } + + /** + * @return array + */ + private function parse_json_body(WP_REST_Request $request): array + { + $json = $request->get_json_params(); + if (is_array($json)) { + return $json; + } + + $body = $request->get_body_params(); + + return is_array($body) ? $body : []; + } +} diff --git a/includes/Api/class-widget-controller.php b/includes/Api/class-widget-controller.php index 83b9763..6355113 100644 --- a/includes/Api/class-widget-controller.php +++ b/includes/Api/class-widget-controller.php @@ -9,8 +9,11 @@ namespace Escalated\Api; +use Escalated\Models\Article; +use Escalated\Models\ArticleCategory; use Escalated\Models\Setting; use Escalated\Models\Ticket; +use Escalated\Services\KnowledgeBaseService; use Escalated\Services\TicketService; use WP_REST_Request; use WP_REST_Server; @@ -81,6 +84,30 @@ public function register_routes(): void ] ); + // Submit helpful / not-helpful feedback for a KB article. + register_rest_route( + $this->namespace, + '/'.$this->rest_base.'/articles/(?P[a-z0-9-]+)/feedback', + [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'submit_feedback'], + 'permission_callback' => [$this, 'widget_enabled_check'], + 'args' => [ + 'slug' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_title', + ], + 'helpful' => [ + 'required' => true, + 'type' => 'boolean', + ], + ], + ], + ] + ); + // Create a ticket via widget. register_rest_route( $this->namespace, @@ -186,33 +213,29 @@ public function get_config() } /** - * Get KB articles for the widget. + * Get published KB articles for the widget. + * + * Reads from the escalated_articles table (previously this queried a + * never-registered `escalated_article` custom post type and always + * returned nothing). */ public function get_articles(WP_REST_Request $request) { $search = $request->get_param('search'); - $args = [ - 'post_type' => 'escalated_article', - 'post_status' => 'publish', - 'posts_per_page' => 10, - 'orderby' => 'date', - 'order' => 'DESC', - ]; - + $filters = ['limit' => 10]; if (! empty($search)) { - $args['s'] = $search; + $filters['search'] = $search; } - $query = new \WP_Query($args); $articles = []; - - foreach ($query->posts as $post) { + foreach (Article::published($filters) as $row) { $articles[] = [ - 'id' => $post->ID, - 'title' => $post->post_title, - 'slug' => $post->post_name, - 'excerpt' => wp_trim_words($post->post_content, 30), + 'id' => (int) $row->id, + 'title' => $row->title, + 'slug' => $row->slug, + 'excerpt' => wp_trim_words(wp_strip_all_tags((string) $row->body), 30), + 'category' => $this->article_category($row), ]; } @@ -220,34 +243,84 @@ public function get_articles(WP_REST_Request $request) } /** - * Get a single KB article by slug. + * Get a single published KB article by slug. Increments the view counter + * and returns related articles from the same category. */ public function get_article(WP_REST_Request $request) { $slug = $request->get_param('slug'); - $posts = get_posts([ - 'post_type' => 'escalated_article', - 'post_status' => 'publish', - 'name' => $slug, - 'numberposts' => 1, - ]); + $article = Article::find_published_by_slug($slug); - if (empty($posts)) { + if (! $article) { return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); } - $post = $posts[0]; + Article::increment_views($article->id); + + $related = []; + foreach (Article::related($article->category_id, $article->id, 5) as $row) { + $related[] = [ + 'id' => (int) $row->id, + 'title' => $row->title, + 'slug' => $row->slug, + ]; + } return $this->success([ - 'id' => $post->ID, - 'title' => $post->post_title, - 'slug' => $post->post_name, - 'content' => wp_kses_post($post->post_content), - 'date' => $post->post_date, + 'id' => (int) $article->id, + 'title' => $article->title, + 'slug' => $article->slug, + 'content' => wp_kses_post((string) $article->body), + 'category' => $this->article_category($article), + 'related' => $related, + 'feedback_enabled' => KnowledgeBaseService::is_feedback_enabled(), + 'date' => $article->published_at ?: $article->created_at, ]); } + /** + * Record helpful / not-helpful feedback for a published article. + */ + public function submit_feedback(WP_REST_Request $request) + { + if (! KnowledgeBaseService::is_feedback_enabled()) { + return $this->error('escalated_feedback_disabled', __('Article feedback is disabled.', 'escalated'), 404); + } + + $article = Article::find_published_by_slug($request->get_param('slug')); + if (! $article) { + return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); + } + + if (rest_sanitize_boolean($request->get_param('helpful'))) { + Article::mark_helpful($article->id); + } else { + Article::mark_not_helpful($article->id); + } + + return $this->success(['message' => __('Thank you for your feedback!', 'escalated')]); + } + + /** + * Resolve an article's category to a lightweight {id,name,slug} array. + * + * @return array|null + */ + private function article_category($article) + { + if (empty($article->category_id)) { + return null; + } + + $category = ArticleCategory::find((int) $article->category_id); + if (! $category) { + return null; + } + + return ['id' => (int) $category->id, 'name' => $category->name, 'slug' => $category->slug]; + } + /** * Create a ticket via the widget (guest ticket). */ diff --git a/includes/Models/Article.php b/includes/Models/Article.php new file mode 100644 index 0000000..331dc6e --- /dev/null +++ b/includes/Models/Article.php @@ -0,0 +1,331 @@ +get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id) + ); + } + + /** + * Find an article by slug (any status). + * + * @param string $slug + * @return object|null + */ + public static function find_by_slug($slug) + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE slug = %s", $slug) + ); + } + + /** + * Find a PUBLISHED article by slug. Unpublished (draft) articles are never + * returned to the public. + * + * @param string $slug + * @return object|null + */ + public static function find_published_by_slug($slug) + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE slug = %s AND status = 'published'", + $slug + ) + ); + } + + /** + * Create a new article. + * + * @return int|false Inserted ID or false on failure. + */ + public static function create(array $data) + { + global $wpdb; + $table = static::table(); + $now = current_time('mysql'); + + $data['created_at'] = $now; + $data['updated_at'] = $now; + + $result = $wpdb->insert($table, $data); + + return $result !== false ? $wpdb->insert_id : false; + } + + /** + * Update an article. + * + * @param int $id + * @return bool + */ + public static function update($id, array $data) + { + global $wpdb; + $table = static::table(); + + $data['updated_at'] = current_time('mysql'); + + return $wpdb->update($table, $data, ['id' => $id]) !== false; + } + + /** + * Delete an article. + * + * @param int $id + * @return bool + */ + public static function delete($id) + { + global $wpdb; + $table = static::table(); + + return $wpdb->delete($table, ['id' => $id]) !== false; + } + + /** + * Admin listing with optional filters: search, status, category_id. + * + * @return array + */ + public static function all(array $filters = []) + { + global $wpdb; + $table = static::table(); + $where = ['1=1']; + $values = []; + + if (! empty($filters['search'])) { + $like = '%'.$wpdb->esc_like($filters['search']).'%'; + $where[] = '(title LIKE %s OR body LIKE %s)'; + $values[] = $like; + $values[] = $like; + } + + if (! empty($filters['status'])) { + $where[] = 'status = %s'; + $values[] = $filters['status']; + } + + if (! empty($filters['category_id'])) { + $where[] = 'category_id = %d'; + $values[] = (int) $filters['category_id']; + } + + $where_clause = implode(' AND ', $where); + $sql = "SELECT * FROM {$table} WHERE {$where_clause} ORDER BY created_at DESC, id DESC"; + + if (! empty($values)) { + $sql = $wpdb->prepare($sql, $values); + } + + return $wpdb->get_results($sql) ?: []; + } + + /** + * Public listing of PUBLISHED articles with optional search + category + * filter, newest published first. + * + * @return array + */ + public static function published(array $filters = []) + { + global $wpdb; + $table = static::table(); + $where = ["status = 'published'"]; + $values = []; + + if (! empty($filters['search'])) { + $like = '%'.$wpdb->esc_like($filters['search']).'%'; + $where[] = '(title LIKE %s OR body LIKE %s)'; + $values[] = $like; + $values[] = $like; + } + + if (! empty($filters['category_id'])) { + $where[] = 'category_id = %d'; + $values[] = (int) $filters['category_id']; + } + + $where_clause = implode(' AND ', $where); + $sql = "SELECT * FROM {$table} WHERE {$where_clause} ORDER BY published_at DESC, id DESC"; + + $limit = isset($filters['limit']) ? (int) $filters['limit'] : 0; + if ($limit > 0) { + $sql .= ' LIMIT '.$limit; + } + + if (! empty($values)) { + $sql = $wpdb->prepare($sql, $values); + } + + return $wpdb->get_results($sql) ?: []; + } + + /** + * Other published articles in the same category (excluding one), newest + * first. Used to build the "related articles" list. + * + * @param int|null $category_id + * @param int $exclude_id + * @param int $limit + * @return array + */ + public static function related($category_id, $exclude_id, $limit = 5) + { + global $wpdb; + $table = static::table(); + + if (empty($category_id)) { + return []; + } + + return $wpdb->get_results($wpdb->prepare( + "SELECT id, title, slug FROM {$table} + WHERE status = 'published' AND category_id = %d AND id != %d + ORDER BY published_at DESC, id DESC + LIMIT %d", + (int) $category_id, + (int) $exclude_id, + (int) $limit + )) ?: []; + } + + /** + * Increment the view counter for an article. + * + * @param int $id + * @return bool + */ + public static function increment_views($id) + { + return static::bump_counter($id, 'view_count'); + } + + /** + * Increment the helpful counter for an article. + * + * @param int $id + * @return bool + */ + public static function mark_helpful($id) + { + return static::bump_counter($id, 'helpful_count'); + } + + /** + * Increment the not-helpful counter for an article. + * + * @param int $id + * @return bool + */ + public static function mark_not_helpful($id) + { + return static::bump_counter($id, 'not_helpful_count'); + } + + /** + * Atomically increment one of the integer counter columns. + * + * @param int $id + * @param string $column One of view_count|helpful_count|not_helpful_count. + * @return bool + */ + private static function bump_counter($id, $column) + { + global $wpdb; + $table = static::table(); + + $allowed = ['view_count', 'helpful_count', 'not_helpful_count']; + if (! in_array($column, $allowed, true)) { + return false; + } + + return $wpdb->query($wpdb->prepare( + "UPDATE {$table} SET {$column} = {$column} + 1 WHERE id = %d", + (int) $id + )) !== false; + } + + /** + * Produce a unique slug derived from $base, appending -2, -3, ... on + * collision. Optionally excludes a row (for updates). + * + * @param string $base A pre-sanitised slug candidate. + * @param int|null $exclude_id + * @return string + */ + public static function unique_slug($base, $exclude_id = null) + { + global $wpdb; + $table = static::table(); + + $base = $base !== '' ? $base : 'article'; + $slug = $base; + $suffix = 2; + + while (true) { + if ($exclude_id) { + $exists = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE slug = %s AND id != %d", + $slug, + (int) $exclude_id + )); + } else { + $exists = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE slug = %s", + $slug + )); + } + + if (! $exists) { + return $slug; + } + + $slug = $base.'-'.$suffix; + $suffix++; + } + } +} diff --git a/includes/Models/ArticleCategory.php b/includes/Models/ArticleCategory.php new file mode 100644 index 0000000..d8e0a22 --- /dev/null +++ b/includes/Models/ArticleCategory.php @@ -0,0 +1,205 @@ +get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id) + ); + } + + /** + * Find a category by slug. + * + * @param string $slug + * @return object|null + */ + public static function find_by_slug($slug) + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE slug = %s", $slug) + ); + } + + /** + * Create a new category. + * + * @return int|false Inserted ID or false on failure. + */ + public static function create(array $data) + { + global $wpdb; + $table = static::table(); + $now = current_time('mysql'); + + $data['created_at'] = $now; + $data['updated_at'] = $now; + + $result = $wpdb->insert($table, $data); + + return $result !== false ? $wpdb->insert_id : false; + } + + /** + * Update a category. + * + * @param int $id + * @return bool + */ + public static function update($id, array $data) + { + global $wpdb; + $table = static::table(); + + $data['updated_at'] = current_time('mysql'); + + return $wpdb->update($table, $data, ['id' => $id]) !== false; + } + + /** + * Delete a category. + * + * @param int $id + * @return bool + */ + public static function delete($id) + { + global $wpdb; + $table = static::table(); + + return $wpdb->delete($table, ['id' => $id]) !== false; + } + + /** + * All categories ordered by position then name. + * + * @return array + */ + public static function all() + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_results( + "SELECT * FROM {$table} ORDER BY position ASC, name ASC" + ) ?: []; + } + + /** + * All categories with a count of their (non-scoped) articles. Used by the + * admin category index. + * + * @return array + */ + public static function all_with_article_counts() + { + global $wpdb; + $table = static::table(); + $articles = Article::table(); + + $sql = "SELECT c.*, ( + SELECT COUNT(*) FROM {$articles} a WHERE a.category_id = c.id + ) AS articles_count + FROM {$table} c + ORDER BY c.position ASC, c.name ASC"; + + return $wpdb->get_results($sql) ?: []; + } + + /** + * Root categories (no parent) with a count of their PUBLISHED articles. + * Used by the public knowledge base index. + * + * @return array + */ + public static function roots_with_published_counts() + { + global $wpdb; + $table = static::table(); + $articles = Article::table(); + + $sql = "SELECT c.*, ( + SELECT COUNT(*) FROM {$articles} a + WHERE a.category_id = c.id AND a.status = 'published' + ) AS articles_count + FROM {$table} c + WHERE c.parent_id IS NULL + ORDER BY c.position ASC, c.name ASC"; + + return $wpdb->get_results($sql) ?: []; + } + + /** + * Produce a unique slug derived from $base, appending -2, -3, ... on + * collision. Optionally excludes a row (for updates). + * + * @param string $base A pre-sanitised slug candidate. + * @param int|null $exclude_id + * @return string + */ + public static function unique_slug($base, $exclude_id = null) + { + global $wpdb; + $table = static::table(); + + $base = $base !== '' ? $base : 'category'; + $slug = $base; + $suffix = 2; + + while (true) { + if ($exclude_id) { + $exists = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE slug = %s AND id != %d", + $slug, + (int) $exclude_id + )); + } else { + $exists = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE slug = %s", + $slug + )); + } + + if (! $exists) { + return $slug; + } + + $slug = $base.'-'.$suffix; + $suffix++; + } + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index 239db20..ee60d8b 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -645,6 +645,49 @@ private static function create_tables(): void UNIQUE KEY user_id (user_id) ) $charset_collate;"; dbDelta($sql); + + // 35. escalated_article_categories — knowledge base category tree. + // Self-referencing parent_id, ordered by position then name. Mirrors + // the Laravel reference article_categories schema. + $sql = "CREATE TABLE {$prefix}article_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + parent_id BIGINT UNSIGNED NULL, + position INT UNSIGNED NOT NULL DEFAULT 0, + description TEXT NULL, + created_at DATETIME, + updated_at DATETIME, + PRIMARY KEY (id), + UNIQUE KEY slug (slug), + KEY parent_id (parent_id) + ) $charset_collate;"; + dbDelta($sql); + + // 36. escalated_articles — knowledge base articles. draft/published + // status with published_at, a unique slug, optional category + author, + // and view/helpful counters. Mirrors the Laravel reference articles + // schema and replaces the never-registered escalated_article CPT. + $sql = "CREATE TABLE {$prefix}articles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category_id BIGINT UNSIGNED NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + body LONGTEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + author_id BIGINT UNSIGNED NULL, + view_count INT UNSIGNED NOT NULL DEFAULT 0, + helpful_count INT UNSIGNED NOT NULL DEFAULT 0, + not_helpful_count INT UNSIGNED NOT NULL DEFAULT 0, + published_at DATETIME NULL, + created_at DATETIME, + updated_at DATETIME, + PRIMARY KEY (id), + UNIQUE KEY slug (slug), + KEY category_id (category_id), + KEY status (status) + ) $charset_collate;"; + dbDelta($sql); } /** diff --git a/tests/Test_Knowledge_Base_Api.php b/tests/Test_Knowledge_Base_Api.php new file mode 100644 index 0000000..6edb7b6 --- /dev/null +++ b/tests/Test_Knowledge_Base_Api.php @@ -0,0 +1,365 @@ + public list/show, unpublished + * articles excluded from the public surface, category assignment, capability + * gating, view-count increment, and helpful/not-helpful feedback. + */ + +use Escalated\Models\Article; +use Escalated\Models\ArticleCategory; +use Escalated\Models\Setting; + +class Test_Knowledge_Base_Api extends WP_UnitTestCase +{ + private int $admin_id; + + private WP_REST_Server $server; + + public function set_up(): void + { + parent::set_up(); + + \Escalated\Activator::activate(); + + // Public widget endpoints are gated behind the widget_enabled flag. + Setting::set('widget_enabled', '1'); + + $this->admin_id = $this->factory->user->create(['role' => 'escalated_admin']); + + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server; + do_action('rest_api_init'); + } + + public function tear_down(): void + { + global $wp_rest_server; + $wp_rest_server = null; + parent::tear_down(); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private function json_request(string $method, string $route, ?array $body = null): WP_REST_Request + { + $request = new WP_REST_Request($method, $route); + if ($body !== null) { + $request->set_header('Content-Type', 'application/json'); + $request->set_body(wp_json_encode($body)); + } + + return $request; + } + + /** + * @return array + */ + private function public_article_slugs(): array + { + $request = new WP_REST_Request('GET', '/escalated/v1/widget/articles'); + $response = $this->server->dispatch($request); + $this->assertEquals(200, $response->get_status()); + + return array_map(static fn ($a) => $a['slug'], $response->get_data()); + } + + private function create_article_as_admin(array $body): array + { + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/articles', $body)); + $this->assertEquals(201, $response->get_status()); + + return $response->get_data(); + } + + // ===================================================================== + // Schema + // ===================================================================== + + public function test_kb_tables_created(): void + { + global $wpdb; + $existing = $wpdb->get_col('SHOW TABLES'); + + $this->assertContains($wpdb->prefix.'escalated_articles', $existing); + $this->assertContains($wpdb->prefix.'escalated_article_categories', $existing); + } + + // ===================================================================== + // Capability gating + // ===================================================================== + + public function test_admin_articles_index_requires_auth(): void + { + wp_set_current_user(0); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/kb/articles')); + $this->assertEquals(401, $response->get_status()); + } + + public function test_agent_can_view_but_not_create_articles(): void + { + $agent_id = $this->factory->user->create(['role' => 'escalated_agent']); + wp_set_current_user($agent_id); + + // kb.view is granted to agents. + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/kb/articles')); + $this->assertEquals(200, $response->get_status()); + + // kb.create is not. + $response = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/articles', [ + 'title' => 'Nope', + 'status' => 'draft', + ])); + $this->assertEquals(403, $response->get_status()); + } + + // ===================================================================== + // Admin create -> public list / show + // ===================================================================== + + public function test_admin_create_then_public_list_and_show(): void + { + $created = $this->create_article_as_admin([ + 'title' => 'Reset your password', + 'body' => '

Click the reset link.

', + 'status' => 'published', + ]); + + $this->assertGreaterThan(0, $created['id']); + $this->assertSame('reset-your-password', $created['article']['slug']); + $this->assertSame($this->admin_id, $created['article']['author_id']); + $this->assertNotEmpty($created['article']['published_at']); + + // Admin index lists it. + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/kb/articles')); + $this->assertEquals(200, $response->get_status()); + $titles = array_map(static fn ($a) => $a['title'], $response->get_data()['articles']); + $this->assertContains('Reset your password', $titles); + + // Public list (logged out) includes it. + wp_set_current_user(0); + $this->assertContains('reset-your-password', $this->public_article_slugs()); + + // Public show returns the sanitised body. + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/widget/articles/reset-your-password')); + $this->assertEquals(200, $response->get_status()); + $data = $response->get_data(); + $this->assertSame($created['id'], $data['id']); + $this->assertStringContainsString('Click the reset link.', $data['content']); + } + + public function test_draft_article_excluded_from_public(): void + { + $this->create_article_as_admin([ + 'title' => 'Secret draft', + 'body' => 'Not for the public yet.', + 'status' => 'draft', + ]); + + // Absent from the public list. + wp_set_current_user(0); + $this->assertNotContains('secret-draft', $this->public_article_slugs()); + + // Public show 404s. + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/widget/articles/secret-draft')); + $this->assertEquals(404, $response->get_status()); + + // But the admin draft filter still surfaces it. + wp_set_current_user($this->admin_id); + $request = new WP_REST_Request('GET', '/escalated/v1/admin/kb/articles'); + $request->set_param('status', 'draft'); + $response = $this->server->dispatch($request); + $titles = array_map(static fn ($a) => $a['title'], $response->get_data()['articles']); + $this->assertContains('Secret draft', $titles); + } + + public function test_update_publishes_draft_and_stamps_published_at(): void + { + $created = $this->create_article_as_admin([ + 'title' => 'Draft to publish', + 'status' => 'draft', + ]); + $id = $created['id']; + $this->assertEmpty(Article::find($id)->published_at); + + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch($this->json_request('PATCH', '/escalated/v1/admin/kb/articles/'.$id, [ + 'title' => 'Draft to publish', + 'status' => 'published', + ])); + $this->assertEquals(200, $response->get_status()); + $this->assertNotEmpty(Article::find($id)->published_at); + + // Now publicly visible. + wp_set_current_user(0); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/widget/articles/draft-to-publish')); + $this->assertEquals(200, $response->get_status()); + } + + public function test_delete_article_removes_it(): void + { + $created = $this->create_article_as_admin([ + 'title' => 'Temporary', + 'status' => 'published', + ]); + $id = $created['id']; + + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch(new WP_REST_Request('DELETE', '/escalated/v1/admin/kb/articles/'.$id)); + $this->assertEquals(204, $response->get_status()); + $this->assertNull(Article::find($id)); + } + + // ===================================================================== + // Category assignment + CRUD + // ===================================================================== + + public function test_article_category_assignment(): void + { + wp_set_current_user($this->admin_id); + + $cat = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/categories', [ + 'name' => 'Billing', + ])); + $this->assertEquals(201, $cat->get_status()); + $cat_id = $cat->get_data()['id']; + $this->assertSame('billing', $cat->get_data()['category']['slug']); + + $article = $this->create_article_as_admin([ + 'title' => 'Refunds', + 'status' => 'published', + 'category_id' => $cat_id, + ]); + $art_id = $article['id']; + + // Admin show reflects the category. + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/kb/articles/'.$art_id)); + $data = $response->get_data(); + $this->assertSame($cat_id, $data['article']['category_id']); + $this->assertSame('Billing', $data['article']['category']['name']); + + // Public show exposes the category too. + wp_set_current_user(0); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/widget/articles/refunds')); + $this->assertEquals(200, $response->get_status()); + $this->assertSame('Billing', $response->get_data()['category']['name']); + } + + public function test_invalid_category_rejected(): void + { + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/articles', [ + 'title' => 'Bad category', + 'status' => 'draft', + 'category_id' => 999999, + ])); + $this->assertEquals(422, $response->get_status()); + } + + public function test_category_index_counts_and_update_delete(): void + { + wp_set_current_user($this->admin_id); + + $cat = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/categories', [ + 'name' => 'Getting Started', + ])); + $cat_id = $cat->get_data()['id']; + + $this->create_article_as_admin([ + 'title' => 'First steps', + 'status' => 'published', + 'category_id' => $cat_id, + ]); + + // Index reports the article count for the category. + wp_set_current_user($this->admin_id); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/kb/categories')); + $this->assertEquals(200, $response->get_status()); + $row = null; + foreach ($response->get_data()['categories'] as $c) { + if ($c['id'] === $cat_id) { + $row = $c; + } + } + $this->assertNotNull($row); + $this->assertSame(1, $row['articles_count']); + + // Update. + $response = $this->server->dispatch($this->json_request('PATCH', '/escalated/v1/admin/kb/categories/'.$cat_id, [ + 'name' => 'Getting Started Guide', + ])); + $this->assertEquals(200, $response->get_status()); + $this->assertSame('Getting Started Guide', ArticleCategory::find($cat_id)->name); + + // Delete. + $response = $this->server->dispatch(new WP_REST_Request('DELETE', '/escalated/v1/admin/kb/categories/'.$cat_id)); + $this->assertEquals(204, $response->get_status()); + $this->assertNull(ArticleCategory::find($cat_id)); + } + + // ===================================================================== + // View counter + feedback + // ===================================================================== + + public function test_show_increments_view_count(): void + { + $created = $this->create_article_as_admin([ + 'title' => 'Track a shipment', + 'status' => 'published', + ]); + $id = $created['id']; + + wp_set_current_user(0); + $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/widget/articles/track-a-shipment')); + $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/widget/articles/track-a-shipment')); + + $this->assertSame(2, (int) Article::find($id)->view_count); + } + + public function test_feedback_records_helpful_and_not_helpful(): void + { + $created = $this->create_article_as_admin([ + 'title' => 'Was this useful', + 'status' => 'published', + ]); + $id = $created['id']; + + wp_set_current_user(0); + + $helpful = new WP_REST_Request('POST', '/escalated/v1/widget/articles/was-this-useful/feedback'); + $helpful->set_param('helpful', true); + $this->assertEquals(200, $this->server->dispatch($helpful)->get_status()); + + $not_helpful = new WP_REST_Request('POST', '/escalated/v1/widget/articles/was-this-useful/feedback'); + $not_helpful->set_param('helpful', false); + $this->assertEquals(200, $this->server->dispatch($not_helpful)->get_status()); + + $article = Article::find($id); + $this->assertSame(1, (int) $article->helpful_count); + $this->assertSame(1, (int) $article->not_helpful_count); + } + + public function test_feedback_disabled_returns_404(): void + { + Setting::set('knowledge_base_feedback_enabled', '0'); + + $this->create_article_as_admin([ + 'title' => 'No feedback here', + 'status' => 'published', + ]); + + wp_set_current_user(0); + $request = new WP_REST_Request('POST', '/escalated/v1/widget/articles/no-feedback-here/feedback'); + $request->set_param('helpful', true); + $this->assertEquals(404, $this->server->dispatch($request)->get_status()); + } +}