From 849ffa1260ec398cf6236a3e5690208c515074f7 Mon Sep 17 00:00:00 2001 From: Terence Ong Date: Mon, 6 Apr 2026 13:41:47 +0700 Subject: [PATCH] Add CI/CD pipeline with automated testing and SFTP deployment Introduces GitHub Actions workflows for continuous integration and deployment. CI runs PHP linting (7.4, 8.1, 8.3), PHPCS with WordPress coding standards, PHPUnit tests across PHP versions, and JS syntax checks. Deployment to Hostinger via FTPS triggers on push to main after all CI checks pass, with optional LiteSpeed cache purge. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 90 +++++++++++++++ .github/workflows/deploy.yml | 76 +++++++++++++ .phpcs.xml.dist | 50 +++++++++ composer.json | 37 ++++++ phpunit.xml.dist | 26 +++++ tests/bootstrap.php | 175 +++++++++++++++++++++++++++++ tests/unit/AdminTest.php | 109 ++++++++++++++++++ tests/unit/FetcherTest.php | 81 +++++++++++++ tests/unit/PluginBootstrapTest.php | 84 ++++++++++++++ tests/unit/RestApiTest.php | 102 +++++++++++++++++ 10 files changed, 830 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .phpcs.xml.dist create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 tests/bootstrap.php create mode 100644 tests/unit/AdminTest.php create mode 100644 tests/unit/FetcherTest.php create mode 100644 tests/unit/PluginBootstrapTest.php create mode 100644 tests/unit/RestApiTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f572fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI — Lint & Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + # ── PHP Syntax Check ───────────────────────────────────────────── + lint-php: + name: PHP Lint (${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.4', '8.1', '8.3'] + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer + + - name: Syntax check all PHP files + run: find . -name '*.php' -not -path './vendor/*' -not -path './peptide-news-plugin/*' -print0 | xargs -0 -n1 php -l + + # ── PHPCS Coding Standards ─────────────────────────────────────── + phpcs: + name: PHPCS + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPCS + run: vendor/bin/phpcs --standard=.phpcs.xml.dist + + # ── PHPUnit Tests ──────────────────────────────────────────────── + phpunit: + name: PHPUnit (${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.4', '8.1', '8.3'] + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer + coverage: xdebug + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPUnit + run: vendor/bin/phpunit --testdox + + # ── JavaScript Lint ────────────────────────────────────────────── + lint-js: + name: JS Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check JS syntax + run: | + for f in $(find . -name '*.js' -not -path './vendor/*' -not -path './node_modules/*' -not -path './peptide-news-plugin/*'); do + node --check "$f" || exit 1 + done diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..072424c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,76 @@ +name: Deploy to WordPress + +on: + push: + branches: [ main ] + +permissions: + contents: read + +# Cancel previous deploys if a new push arrives while one is running. +concurrency: + group: deploy-production + cancel-in-progress: true + +jobs: + # ── Run tests first ────────────────────────────────────────────── + test: + name: Pre-deploy tests + uses: ./.github/workflows/ci.yml + + # ── Deploy via SFTP ────────────────────────────────────────────── + deploy: + name: Deploy to Hostinger + runs-on: ubuntu-latest + needs: test # Only deploy if all CI checks pass + environment: production + steps: + - uses: actions/checkout@v4 + + - name: Deploy plugin files via SFTP + uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + with: + server: ${{ secrets.SFTP_HOST }} + username: ${{ secrets.SFTP_USER }} + password: ${{ secrets.SFTP_PASS }} + port: ${{ secrets.SFTP_PORT || 22 }} + protocol: ftps + local-dir: ./ + server-dir: ${{ secrets.WP_PLUGIN_PATH }} + exclude: | + **/.git* + **/.git*/** + .github/** + tests/** + vendor/** + node_modules/** + composer.json + composer.lock + phpunit.xml.dist + .phpcs.xml.dist + peptide-news-plugin/** + README.md + .editorconfig + + - name: Purge LiteSpeed Cache + if: success() + run: | + # Trigger a cache purge via the WordPress REST API. + # This requires an Application Password set up in WordPress. + if [ -n "${{ secrets.WP_APP_PASSWORD }}" ]; then + curl -s -X POST \ + -u "${{ secrets.WP_ADMIN_USER }}:${{ secrets.WP_APP_PASSWORD }}" \ + "${{ secrets.WP_SITE_URL }}/wp-json/peptide-news/v1/cache-purge" \ + --max-time 15 || echo "Cache purge skipped (endpoint not available)" + else + echo "No WP_APP_PASSWORD set — skipping remote cache purge." + echo "Remember to purge LiteSpeed Cache manually after deploy." + fi + + - name: Deploy summary + run: | + echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Deployed to:** ${{ secrets.WP_SITE_URL || 'peptiderepo.com' }}" >> $GITHUB_STEP_SUMMARY diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist new file mode 100644 index 0000000..893aebd --- /dev/null +++ b/.phpcs.xml.dist @@ -0,0 +1,50 @@ + + + PHPCS rules for the Peptide News WordPress plugin. + + + ./peptide-news-plugin.php + ./includes + ./admin + ./public + + + ./vendor/* + ./node_modules/* + ./tests/* + ./peptide-news-plugin/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..eb532a1 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "peptiderepo/peptide-news-plugin", + "description": "WordPress plugin for peptide research news aggregation with click analytics.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "wp-phpunit/wp-phpunit": "^6.4", + "squizlabs/php_codesniffer": "^3.9", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "autoload-dev": { + "psr-4": { + "PeptideNews\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "lint": "phpcs", + "lint:fix": "phpcbf", + "check": [ + "@lint", + "@test" + ] + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..56d13db --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + ./tests/unit + + + + + + ./includes + ./admin + ./public + + + ./admin/partials + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..2e92d86 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,175 @@ +errors[ $code ][] = $message; + if ( $data ) { + $this->error_data[ $code ] = $data; + } + } + } + + public function get_error_code() { + $codes = array_keys( $this->errors ); + return $codes ? $codes[0] : ''; + } + + public function get_error_message( $code = '' ) { + if ( ! $code ) { + $code = $this->get_error_code(); + } + return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : ''; + } + } +} + +// Stub WP_REST_Request for REST API tests. +if ( ! class_exists( 'WP_REST_Request' ) ) { + class WP_REST_Request { + private $params = array(); + private $method = 'GET'; + + public function __construct( $method = 'GET', $route = '' ) { + $this->method = $method; + } + + public function set_param( $key, $value ) { + $this->params[ $key ] = $value; + } + + public function get_param( $key ) { + return isset( $this->params[ $key ] ) ? $this->params[ $key ] : null; + } + + public function get_params() { + return $this->params; + } + } +} diff --git a/tests/unit/AdminTest.php b/tests/unit/AdminTest.php new file mode 100644 index 0000000..ea35c3c --- /dev/null +++ b/tests/unit/AdminTest.php @@ -0,0 +1,109 @@ +admin = new \Peptide_News_Admin( 'peptide-news', '1.2.1' ); + + // Reset global test state. + global $_test_settings_errors; + $_test_settings_errors = array(); + } + + /** + * Constructor should set plugin_name property. + */ + public function test_constructor_sets_plugin_name(): void { + $reflection = new \ReflectionClass( $this->admin ); + $prop = $reflection->getProperty( 'plugin_name' ); + $prop->setAccessible( true ); + + $this->assertEquals( 'peptide-news', $prop->getValue( $this->admin ) ); + } + + /** + * Constructor should set version property. + */ + public function test_constructor_sets_version(): void { + $reflection = new \ReflectionClass( $this->admin ); + $prop = $reflection->getProperty( 'version' ); + $prop->setAccessible( true ); + + $this->assertEquals( '1.2.1', $prop->getValue( $this->admin ) ); + } + + /** + * sanitize_model_id() should accept a valid model ID. + */ + public function test_sanitize_model_id_accepts_valid_id(): void { + $valid = 'openai/gpt-4o-mini'; + $this->assertEquals( $valid, $this->admin->sanitize_model_id( $valid ) ); + } + + /** + * sanitize_model_id() should accept model IDs with colons and dots. + */ + public function test_sanitize_model_id_accepts_complex_ids(): void { + $valid = 'google/gemini-2.0-flash:free'; + $this->assertEquals( $valid, $this->admin->sanitize_model_id( $valid ) ); + } + + /** + * sanitize_model_id() should reject empty strings. + */ + public function test_sanitize_model_id_allows_empty_to_clear(): void { + $result = $this->admin->sanitize_model_id( '' ); + $this->assertEquals( '', $result ); + } + + /** + * sanitize_model_id() should reject strings with spaces. + */ + public function test_sanitize_model_id_rejects_spaces(): void { + global $_test_settings_errors; + + $result = $this->admin->sanitize_model_id( 'invalid model id' ); + + $this->assertNotEmpty( $_test_settings_errors, 'Expected a settings error for invalid model ID.' ); + } + + /** + * sanitize_model_id() should reject strings with special characters. + */ + public function test_sanitize_model_id_rejects_special_chars(): void { + global $_test_settings_errors; + + $result = $this->admin->sanitize_model_id( '' ); + + $this->assertNotEmpty( $_test_settings_errors, 'Expected a settings error for XSS attempt.' ); + } + + /** + * enqueue_styles() should not enqueue on non-plugin pages. + * + * We verify it returns early by checking no errors are thrown + * when called with a non-matching hook. + */ + public function test_enqueue_styles_skips_non_plugin_pages(): void { + // Should return without error when hook doesn't match. + $this->admin->enqueue_styles( 'edit.php' ); + $this->assertTrue( true ); // No exception = pass. + } +} diff --git a/tests/unit/FetcherTest.php b/tests/unit/FetcherTest.php new file mode 100644 index 0000000..2bd8e11 --- /dev/null +++ b/tests/unit/FetcherTest.php @@ -0,0 +1,81 @@ +fetcher = new \Peptide_News_Fetcher(); + } + + /** + * add_custom_cron_schedules() should add the expected intervals. + */ + public function test_add_custom_cron_schedules_adds_intervals(): void { + $existing = array( + 'hourly' => array( + 'interval' => 3600, + 'display' => 'Once Hourly', + ), + ); + + $result = $this->fetcher->add_custom_cron_schedules( $existing ); + + // Original schedule must be preserved. + $this->assertArrayHasKey( 'hourly', $result ); + + // Custom schedules must be added. + $this->assertArrayHasKey( 'every_fifteen_minutes', $result ); + $this->assertArrayHasKey( 'every_thirty_minutes', $result ); + $this->assertArrayHasKey( 'every_four_hours', $result ); + $this->assertArrayHasKey( 'every_six_hours', $result ); + } + + /** + * Custom intervals should have correct durations. + */ + public function test_cron_schedule_intervals_are_correct(): void { + $result = $this->fetcher->add_custom_cron_schedules( array() ); + + $this->assertEquals( 900, $result['every_fifteen_minutes']['interval'] ); + $this->assertEquals( 1800, $result['every_thirty_minutes']['interval'] ); + $this->assertEquals( 14400, $result['every_four_hours']['interval'] ); + $this->assertEquals( 21600, $result['every_six_hours']['interval'] ); + } + + /** + * Custom schedules should have display names. + */ + public function test_cron_schedules_have_display_names(): void { + $result = $this->fetcher->add_custom_cron_schedules( array() ); + + foreach ( $result as $key => $schedule ) { + $this->assertArrayHasKey( 'display', $schedule, "Schedule '{$key}' missing 'display' key." ); + $this->assertNotEmpty( $schedule['display'], "Schedule '{$key}' has empty display name." ); + } + } + + /** + * Passing an empty array should still return all custom schedules. + */ + public function test_cron_schedules_work_with_empty_input(): void { + $result = $this->fetcher->add_custom_cron_schedules( array() ); + + $this->assertCount( 4, $result ); + } +} diff --git a/tests/unit/PluginBootstrapTest.php b/tests/unit/PluginBootstrapTest.php new file mode 100644 index 0000000..f642846 --- /dev/null +++ b/tests/unit/PluginBootstrapTest.php @@ -0,0 +1,84 @@ +assertTrue( defined( 'PEPTIDE_NEWS_VERSION' ) ); + $this->assertNotEmpty( PEPTIDE_NEWS_VERSION ); + } + + /** + * Plugin directory constant should point to a real directory. + */ + public function test_plugin_dir_constant_is_valid(): void { + $this->assertTrue( defined( 'PEPTIDE_NEWS_PLUGIN_DIR' ) ); + $this->assertDirectoryExists( PEPTIDE_NEWS_PLUGIN_DIR ); + } + + /** + * Main plugin file should exist. + */ + public function test_main_plugin_file_exists(): void { + $this->assertFileExists( PEPTIDE_NEWS_PLUGIN_DIR . 'peptide-news-plugin.php' ); + } + + /** + * All required class files should exist. + */ + public function test_required_class_files_exist(): void { + $files = array( + 'includes/class-peptide-news.php', + 'includes/class-peptide-news-activator.php', + 'includes/class-peptide-news-deactivator.php', + 'includes/class-peptide-news-fetcher.php', + 'includes/class-peptide-news-analytics.php', + 'includes/class-peptide-news-rest-api.php', + 'admin/class-peptide-news-admin.php', + ); + + foreach ( $files as $file ) { + $path = PEPTIDE_NEWS_PLUGIN_DIR . $file; + $this->assertFileExists( $path, "Required file missing: {$file}" ); + } + } + + /** + * Admin JavaScript file should exist. + */ + public function test_admin_script_exists(): void { + $this->assertFileExists( PEPTIDE_NEWS_PLUGIN_DIR . 'admin/js/admin-script.js' ); + } + + /** + * Admin stylesheet should exist. + */ + public function test_admin_style_exists(): void { + $this->assertFileExists( PEPTIDE_NEWS_PLUGIN_DIR . 'admin/css/admin-style.css' ); + } + + /** + * Version string should follow SemVer pattern. + */ + public function test_version_follows_semver(): void { + // Allow semver with optional pre-release suffix (e.g. 1.2.1-test). + $this->assertMatchesRegularExpression( + '/^\d+\.\d+\.\d+(-[\w.]+)?$/', + PEPTIDE_NEWS_VERSION, + 'Version does not follow SemVer pattern.' + ); + } +} diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php new file mode 100644 index 0000000..07d9ac1 --- /dev/null +++ b/tests/unit/RestApiTest.php @@ -0,0 +1,102 @@ +api = new \Peptide_News_Rest_API(); + } + + /** + * validate_date_format() should accept valid Y-m-d dates. + */ + public function test_validate_date_accepts_valid_format(): void { + $request = new \WP_REST_Request(); + $result = $this->api->validate_date_format( '2026-01-15', $request, 'start_date' ); + + $this->assertTrue( $result ); + } + + /** + * validate_date_format() should accept today's date. + */ + public function test_validate_date_accepts_today(): void { + $request = new \WP_REST_Request(); + $result = $this->api->validate_date_format( date( 'Y-m-d' ), $request, 'end_date' ); + + $this->assertTrue( $result ); + } + + /** + * validate_date_format() should reject invalid formats. + */ + public function test_validate_date_rejects_wrong_format(): void { + $request = new \WP_REST_Request(); + $result = $this->api->validate_date_format( '01-15-2026', $request, 'start_date' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * validate_date_format() should reject empty strings. + */ + public function test_validate_date_rejects_empty(): void { + $request = new \WP_REST_Request(); + $result = $this->api->validate_date_format( '', $request, 'start_date' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * validate_date_format() should reject non-date strings. + */ + public function test_validate_date_rejects_garbage(): void { + $request = new \WP_REST_Request(); + $result = $this->api->validate_date_format( 'not-a-date', $request, 'start_date' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * validate_date_format() should reject dates with slashes. + */ + public function test_validate_date_rejects_slashes(): void { + $request = new \WP_REST_Request(); + $result = $this->api->validate_date_format( '2026/01/15', $request, 'start_date' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * check_admin_permissions() should return WP_Error when user lacks capability. + */ + public function test_check_admin_permissions_denies_non_admin(): void { + // Stub current_user_can to return false. + if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can() { + return false; + } + } + + $request = new \WP_REST_Request(); + $result = $this->api->check_admin_permissions( $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } +}