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 ); + } +}