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