From 1bd65fb8347b38698b52be34351c8c6989f16780 Mon Sep 17 00:00:00 2001 From: sultann Date: Wed, 10 Jun 2026 18:09:24 +0600 Subject: [PATCH] Add PHPUnit baseline test suite --- composer.json | 12 +- phpunit.xml | 38 +++++ tests/.gitignore | 2 + tests/bin/install.sh | 77 ++++++++++ tests/bootstrap.php | 48 +++++++ tests/phpunit/CronExpiryTest.php | 70 +++++++++ tests/phpunit/EncryptionTest.php | 54 +++++++ tests/phpunit/InstallerTest.php | 38 +++++ tests/phpunit/KeyModelTest.php | 127 +++++++++++++++++ tests/phpunit/OrderFulfillmentTest.php | 150 +++++++++++++++++++ tests/phpunit/RestApiActivateTest.php | 134 +++++++++++++++++ tests/phpunit/RestApiValidateTest.php | 148 +++++++++++++++++++ tests/phpunit/StockTest.php | 99 +++++++++++++ tests/phpunit/TestCase.php | 190 +++++++++++++++++++++++++ 14 files changed, 1186 insertions(+), 1 deletion(-) create mode 100644 phpunit.xml create mode 100644 tests/.gitignore create mode 100755 tests/bin/install.sh create mode 100644 tests/bootstrap.php create mode 100644 tests/phpunit/CronExpiryTest.php create mode 100644 tests/phpunit/EncryptionTest.php create mode 100644 tests/phpunit/InstallerTest.php create mode 100644 tests/phpunit/KeyModelTest.php create mode 100644 tests/phpunit/OrderFulfillmentTest.php create mode 100644 tests/phpunit/RestApiActivateTest.php create mode 100644 tests/phpunit/RestApiValidateTest.php create mode 100644 tests/phpunit/StockTest.php create mode 100644 tests/phpunit/TestCase.php diff --git a/composer.json b/composer.json index e2837650..ecd55799 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,21 @@ "pluginever/framework-settings": "dev-trunk" }, "require-dev": { - "byteever/byteever-sniffs": "^1.1.3" + "byteever/byteever-sniffs": "^1.1.3", + "phpunit/phpunit": "^9.6", + "wp-phpunit/wp-phpunit": "^6.8", + "yoast/phpunit-polyfills": "^4.0" }, "autoload": { "psr-4": { "WooCommerceSerialNumbers\\": ["includes/","src/"] } }, + "autoload-dev": { + "psr-4": { + "WooCommerceSerialNumbers\\Tests\\": "tests/phpunit/" + } + }, "config": { "platform": { "php": "7.4" @@ -45,6 +53,8 @@ "makepot": "wp --allow-root i18n make-pot . --include=*.php,assets,includes,templates --domain=wc-serial-numbers", "phpcs": "php ./vendor/bin/phpcs --standard=phpcs.xml -s -v", "phpcbf": "php ./vendor/bin/phpcbf --standard=phpcs.xml -v", + "test:setup": "bash ./tests/bin/install.sh wordpress_test root root localhost latest", + "test": "phpunit", "strauss": [ "test -f /tmp/strauss.phar || curl -o /tmp/strauss.phar -L https://github.com/BrianHenryIE/strauss/releases/download/0.24.1/strauss.phar", "@php /tmp/strauss.phar", diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..433c94c3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,38 @@ + + + + + + ./tests/phpunit + + + + + + + ./src + + + ./vendor + ./tests + + + + + + + + + + + \ No newline at end of file diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 00000000..9b2dce80 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +coverage/ +logs/ diff --git a/tests/bin/install.sh b/tests/bin/install.sh new file mode 100755 index 00000000..3d318bee --- /dev/null +++ b/tests/bin/install.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +set -e + +DB_NAME=${1:-wordpress_test} +DB_USER=${2:-root} +DB_PASS=${3:-''} +DB_HOST=${4:-localhost} +WP_VERSION=${5:-latest} + +WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress} +WC_DIR="$WP_CORE_DIR/wp-content/plugins/woocommerce" + +# prerequisites +for cmd in wp mysql curl unzip; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required but not installed." + exit 1 + fi +done + +echo "Setting up WordPress test environment..." +# Download WordPress. +if [ ! -f "$WP_CORE_DIR/wp-load.php" ]; then + echo "Downloading WordPress..." + if [ "$WP_VERSION" = "latest" ]; then + wp core download --path="$WP_CORE_DIR" --force --allow-root || exit 1 + else + wp core download --path="$WP_CORE_DIR" --version="$WP_VERSION" --force --allow-root || exit 1 + fi +fi + +# Download test suite matching WordPress version. +if [ ! -d "$WP_TESTS_DIR/includes" ]; then + echo "Downloading test suite..." + BRANCH="trunk" + [ "$WP_VERSION" != "latest" ] && BRANCH="$WP_VERSION" + rm -rf /tmp/wordpress-develop + git clone --depth=1 --branch "$BRANCH" https://github.com/WordPress/wordpress-develop.git /tmp/wordpress-develop 2>/dev/null || exit 1 + mkdir -p "$WP_TESTS_DIR" + cp -r /tmp/wordpress-develop/tests/phpunit/{includes,data} "$WP_TESTS_DIR/" || exit 1 + rm -rf /tmp/wordpress-develop +fi + +# Download WooCommerce (the plugin under test requires it to boot). +if [ ! -f "$WC_DIR/woocommerce.php" ]; then + echo "Downloading WooCommerce..." + mkdir -p "$WP_CORE_DIR/wp-content/plugins" + curl -sL "https://downloads.wordpress.org/plugin/woocommerce.zip" -o /tmp/woocommerce.zip || exit 1 + unzip -q -o /tmp/woocommerce.zip -d "$WP_CORE_DIR/wp-content/plugins/" || exit 1 + rm -f /tmp/woocommerce.zip +fi + +# Create config file. +cat > "$WP_TESTS_DIR/wp-tests-config.php" << EOF +/dev/null || exit 1 + +echo "Setup complete." diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..f2d4ec81 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,48 @@ +create_product(); + $key = Key::insert( + array( + 'product_id' => $product->get_id(), + 'serial_key' => $serial, + 'status' => 'sold', + 'validity' => $validity, + 'order_date' => $order_date, + ) + ); + $this->assertNotWPError( $key ); + + return $key; + } + + /** + * A key past its validity window is expired. + */ + public function testExpiresKeyPastValidity(): void { + $key = $this->make_dated_key( 'EXP-1', 30, gmdate( 'Y-m-d H:i:s', strtotime( '-40 days' ) ) ); + + Cron::expire_outdated_serials(); + + $this->assertSame( 'expired', Key::get( $key->get_id() )->get_status() ); + } + + /** + * A key still within its validity window is not expired. + */ + public function testDoesNotExpireWithinValidity(): void { + $key = $this->make_dated_key( 'EXP-2', 30, gmdate( 'Y-m-d H:i:s', strtotime( '-5 days' ) ) ); + + Cron::expire_outdated_serials(); + + $this->assertSame( 'sold', Key::get( $key->get_id() )->get_status() ); + } + + /** + * A key with zero validity (lifetime) never expires. + */ + public function testZeroValidityNeverExpires(): void { + $key = $this->make_dated_key( 'EXP-3', 0, gmdate( 'Y-m-d H:i:s', strtotime( '-400 days' ) ) ); + + Cron::expire_outdated_serials(); + + $this->assertSame( 'sold', Key::get( $key->get_id() )->get_status() ); + } +} diff --git a/tests/phpunit/EncryptionTest.php b/tests/phpunit/EncryptionTest.php new file mode 100644 index 00000000..441599de --- /dev/null +++ b/tests/phpunit/EncryptionTest.php @@ -0,0 +1,54 @@ +assertSame( $plain, wcsn_decrypt_key( wcsn_encrypt_key( $plain ) ) ); + } + + /** + * Encrypting an already-encrypted value leaves it unchanged. + */ + public function testMaybeEncryptIsIdempotent(): void { + $enc = wcsn_encrypt_key( 'LICENSE-KEY-001' ); + $this->assertSame( $enc, wcsn_encrypt_key( $enc ) ); + } + + /** + * isEncrypted() recognises ciphertext but not plaintext. + */ + public function testIsEncryptedDistinguishesCiphertextFromPlaintext(): void { + $enc = wcsn_encrypt_key( 'SOME-PLAIN-KEY' ); + $this->assertTrue( Encryption::isEncrypted( $enc ) ); + $this->assertFalse( Encryption::isEncrypted( 'SOME-PLAIN-KEY' ) ); + } + + /** + * An empty string round-trips to an empty string. + */ + public function testEmptyStringRoundTripsToEmptyString(): void { + $this->assertSame( '', wcsn_decrypt_key( wcsn_encrypt_key( '' ) ) ); + } + + /** + * The fixed IV makes ciphertext deterministic and reversible. + */ + public function testFixedIvProducesStableCiphertext(): void { + $a = wcsn_encrypt_key( 'STABLE-KEY' ); + $b = wcsn_encrypt_key( 'STABLE-KEY' ); + $this->assertSame( $a, $b ); + $this->assertSame( 'STABLE-KEY', wcsn_decrypt_key( $a ) ); + } +} diff --git a/tests/phpunit/InstallerTest.php b/tests/phpunit/InstallerTest.php new file mode 100644 index 00000000..1ea718cb --- /dev/null +++ b/tests/phpunit/InstallerTest.php @@ -0,0 +1,38 @@ +assertSame( "{$wpdb->prefix}serial_numbers", $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}serial_numbers'" ) ); + $this->assertSame( "{$wpdb->prefix}serial_numbers_activations", $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}serial_numbers_activations'" ) ); + + $this->assertNotEmpty( $wpdb->get_var( "SHOW COLUMNS FROM {$wpdb->prefix}serial_numbers LIKE 'uuid'" ) ); + $this->assertNotEmpty( $wpdb->get_var( "SHOW COLUMNS FROM {$wpdb->prefix}serial_numbers LIKE 'order_item_id'" ) ); + } + + /** + * The current plugin version is stored as the db version. + */ + public function testDbVersionStored(): void { + $this->assertSame( WCSN()->get_version(), WCSN()->get_db_version() ); + } + + /** + * Installation records the install date and schedules the hourly cron. + */ + public function testInstallDateAndCronScheduled(): void { + $this->assertNotEmpty( get_option( 'wc_serial_numbers_install_date' ) ); + $this->assertNotFalse( wp_next_scheduled( 'wc_serial_numbers_hourly_event' ) ); + } +} diff --git a/tests/phpunit/KeyModelTest.php b/tests/phpunit/KeyModelTest.php new file mode 100644 index 00000000..edc5e494 --- /dev/null +++ b/tests/phpunit/KeyModelTest.php @@ -0,0 +1,127 @@ +create_product(); + $key = $this->make_available_key( $product->get_id(), 'PLAINTEXT-XYZ' ); + + $raw = $this->get_raw_serial_key( $key->get_id() ); + $this->assertNotSame( 'PLAINTEXT-XYZ', $raw, 'Serial key must not be stored in plaintext.' ); + $this->assertSame( 'PLAINTEXT-XYZ', wcsn_decrypt_key( $raw ) ); + + $reloaded = Key::get( $key->get_id() ); + $this->assertSame( 'PLAINTEXT-XYZ', $reloaded->get_serial_key() ); + } + + /** + * Lookup by plaintext serial key matches the encrypted row. + */ + public function testGetBySerialKeyMatchesEncryptedRow(): void { + $product = $this->create_product(); + $key = $this->make_available_key( $product->get_id(), 'PLAINTEXT-XYZ' ); + + $found = Key::get( + array( + 'serial_key' => 'PLAINTEXT-XYZ', + 'product_id' => $product->get_id(), + ) + ); + + $this->assertInstanceOf( Key::class, $found ); + $this->assertSame( $key->get_id(), $found->get_id() ); + } + + /** + * Saving without a product id is rejected. + */ + public function testSaveRequiresProductId(): void { + $key = new Key(); + $key->set_serial_key( 'NOPROD-1' ); + $this->assertWPError( $key->save() ); + } + + /** + * Saving with an invalid product id is rejected. + */ + public function testSaveRejectsInvalidProduct(): void { + $key = new Key(); + $key->set_product_id( 99999999 ); + $key->set_serial_key( 'BADPROD-1' ); + $this->assertWPError( $key->save() ); + } + + /** + * Duplicate serial keys are rejected by default. + */ + public function testDuplicateSerialKeyRejected(): void { + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'DUP-1' ); + + $key = new Key(); + $key->set_product_id( $product->get_id() ); + $key->set_serial_key( 'DUP-1' ); + $this->assertWPError( $key->save() ); + } + + /** + * set_status() ignores values outside the status whitelist. + */ + public function testSetStatusIgnoresUnknownStatus(): void { + $product = $this->create_product(); + $key = $this->make_available_key( $product->get_id(), 'STAT-1' ); + + $key->set_status( 'refunded' ); + $this->assertSame( 'available', $key->get_status() ); + } + + /** + * Saving as available clears order linkage and activation count. + */ + public function testAvailableStatusClearsOrderFields(): void { + $product = $this->create_product(); + $order = $this->create_order( $product, 1, array( 'status' => 'completed' ) ); + + $key = $this->make_available_key( $product->get_id(), 'CLR-1' ); + $key->set_order_id( $order->get_id() ); + $key->set_status( 'sold' ); + $key->save(); + $this->assertSame( $order->get_id(), $key->get_order_id() ); + + $key->set_status( 'available' ); + $key->save(); + $this->assertSame( 0, $key->get_order_id() ); + $this->assertEmpty( $key->get_order_date() ); + $this->assertSame( 0, $key->get_activation_count() ); + } + + /** + * Expiry date and activations-left math (unlimited returns 9999). + */ + public function testExpiryAndActivationsLeftMath(): void { + $key = new Key(); + $key->set_validity( 30 ); + $key->set_order_date( '2026-01-01 00:00:00' ); + $this->assertSame( '2026-01-31 00:00:00', $key->get_expire_date() ); + + $unlimited = new Key(); + $unlimited->set_activation_limit( 0 ); + $this->assertSame( 9999, $unlimited->get_activations_left() ); + + $limited = new Key(); + $limited->set_activation_limit( 5 ); + $limited->set_activation_count( 2 ); + $this->assertSame( 3, $limited->get_activations_left() ); + } +} diff --git a/tests/phpunit/OrderFulfillmentTest.php b/tests/phpunit/OrderFulfillmentTest.php new file mode 100644 index 00000000..0697a880 --- /dev/null +++ b/tests/phpunit/OrderFulfillmentTest.php @@ -0,0 +1,150 @@ +create_product(); + $this->make_available_key( $product->get_id(), 'K-1' ); + $this->make_available_key( $product->get_id(), 'K-2' ); + $order = $this->create_order( $product, 2, array( 'status' => 'completed' ) ); + + wcsn_order_update_keys( $order->get_id() ); + + $keys = Key::query( array( 'order_id' => $order->get_id(), 'limit' => -1 ) ); + $this->assertCount( 2, $keys ); + foreach ( $keys as $key ) { + $this->assertSame( 'sold', $key->get_status() ); + } + } + + /** + * Not enough available keys assigns nothing. + */ + public function testInsufficientStockAssignsNothing(): void { + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'K-1' ); + $order = $this->create_order( $product, 3, array( 'status' => 'completed' ) ); + + wcsn_order_update_keys( $order->get_id() ); + + $this->assertCount( 0, Key::query( array( 'order_id' => $order->get_id(), 'limit' => -1 ) ) ); + } + + /** + * A fully refunded line item gets no keys assigned. + */ + public function testFullRefundAssignsNoKeys(): void { + $product = $this->create_product(); + // Complete the order before any keys exist, so nothing is assigned at creation. + $order = $this->create_order( $product, 2, array( 'status' => 'completed' ) ); + + $items = $order->get_items(); + $item = current( $items ); + $refund = wc_create_refund( + array( + 'order_id' => $order->get_id(), + 'amount' => 0, + 'line_items' => array( + $item->get_id() => array( + 'qty' => 2, + 'refund_total' => 0, + ), + ), + ) + ); + $this->assertNotWPError( $refund ); + + // Make keys available; the fully refunded item must not pull any. + $this->make_available_key( $product->get_id(), 'K-1' ); + $this->make_available_key( $product->get_id(), 'K-2' ); + + wcsn_order_update_keys( $order->get_id() ); + + $this->assertCount( 0, Key::query( array( 'order_id' => $order->get_id(), 'limit' => -1 ) ) ); + } + + /** + * Cancelling an order revokes its keys to cancelled (reuse off). + */ + public function testCancelRevokesKeysToCancelled(): void { + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'K-1' ); + $order = $this->create_order( $product, 1, array( 'status' => 'completed' ) ); + + wcsn_order_update_keys( $order->get_id() ); + $keys = Key::query( array( 'order_id' => $order->get_id(), 'limit' => -1 ) ); + $this->assertCount( 1, $keys ); + $key_id = $keys[0]->get_id(); + + $order->set_status( 'cancelled' ); + $order->save(); + wcsn_order_update_keys( $order->get_id() ); + + $revoked = Key::get( $key_id ); + $this->assertSame( 'cancelled', $revoked->get_status() ); + $this->assertSame( 0, $revoked->get_order_id() ); + } + + /** + * With reuse enabled, cancelling returns keys to available. + */ + public function testReuseSettingReturnsKeysToAvailable(): void { + $this->set_option( 'wc_serial_numbers_reuse_serial_number', 'yes' ); + + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'K-1' ); + $order = $this->create_order( $product, 1, array( 'status' => 'completed' ) ); + + wcsn_order_update_keys( $order->get_id() ); + $key_id = Key::query( array( 'order_id' => $order->get_id(), 'limit' => -1 ) )[0]->get_id(); + + $order->set_status( 'cancelled' ); + $order->save(); + wcsn_order_update_keys( $order->get_id() ); + + $this->assertSame( 'available', Key::get( $key_id )->get_status() ); + } + + /** + * Running fulfillment twice does not assign extra keys. + */ + public function testIdempotentNoDoubleAssign(): void { + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'K-1' ); + $this->make_available_key( $product->get_id(), 'K-2' ); + $this->make_available_key( $product->get_id(), 'K-3' ); + $order = $this->create_order( $product, 2, array( 'status' => 'completed' ) ); + + wcsn_order_update_keys( $order->get_id() ); + wcsn_order_update_keys( $order->get_id() ); + + $this->assertCount( 2, Key::query( array( 'order_id' => $order->get_id(), 'limit' => -1 ) ) ); + $this->assertSame( 1, (int) Key::count( array( 'product_id' => $product->get_id(), 'status' => 'available' ) ) ); + } + + /** + * Autocomplete promotes a processing order to completed when enabled. + */ + public function testAutocompleteOrderStatusFilter(): void { + $product = $this->create_product(); + $order = $this->create_order( $product, 1 ); + + $this->set_option( 'wc_serial_numbers_autocomplete_order', 'yes' ); + $this->assertSame( 'completed', Orders::maybe_autocomplete_order( 'processing', $order->get_id(), $order ) ); + + $this->set_option( 'wc_serial_numbers_autocomplete_order', 'no' ); + $this->assertSame( 'processing', Orders::maybe_autocomplete_order( 'processing', $order->get_id(), $order ) ); + } +} diff --git a/tests/phpunit/RestApiActivateTest.php b/tests/phpunit/RestApiActivateTest.php new file mode 100644 index 00000000..5ad5d371 --- /dev/null +++ b/tests/phpunit/RestApiActivateTest.php @@ -0,0 +1,134 @@ +create_product(); + $order = $this->create_order( $product, 1, array( 'status' => 'completed' ) ); + $key = Key::insert( + array( + 'product_id' => $product->get_id(), + 'serial_key' => $serial, + 'status' => 'sold', + 'order_id' => $order->get_id(), + 'activation_limit' => $limit, + ) + ); + $this->assertNotWPError( $key ); + + return $product; + } + + /** + * Activating creates an activation and decrements the remaining count. + */ + public function testActivateCreatesActivationAndDecrements(): void { + $product = $this->make_activatable( 'ACT-1', 2 ); + $response = rest_do_request( + $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-1', 'instance' => 'inst-1' ) ) + ); + $data = $response->get_data(); + + $this->assertSame( 'key_activated', $data['code'] ); + $this->assertSame( 1, $data['activation_count'] ); + $this->assertSame( 1, $data['activations_left'] ); + } + + /** + * An activation with no instance gets a server-generated one. + */ + public function testActivateGeneratesInstanceWhenMissing(): void { + $product = $this->make_activatable( 'ACT-2', 1 ); + $response = rest_do_request( + $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-2' ) ) + ); + + $this->assertNotEmpty( $response->get_data()['instance'] ); + } + + /** + * Activating the same instance twice is rejected. + */ + public function testDuplicateInstanceRejected(): void { + $product = $this->make_activatable( 'ACT-3', 5 ); + rest_do_request( $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-3', 'instance' => 'dup' ) ) ); + + $response = rest_do_request( + $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-3', 'instance' => 'dup' ) ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'instance_already_activated', $response->get_data()['code'] ); + } + + /** + * Activating past the limit is rejected. + */ + public function testActivationLimitReached(): void { + $product = $this->make_activatable( 'ACT-4', 1 ); + rest_do_request( $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-4', 'instance' => 'one' ) ) ); + + $response = rest_do_request( + $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-4', 'instance' => 'two' ) ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'no_activations_left', $response->get_data()['code'] ); + } + + /** + * Deactivating removes the activation and restores the remaining count. + */ + public function testDeactivateRemovesActivation(): void { + $product = $this->make_activatable( 'ACT-5', 2 ); + rest_do_request( $this->get_rest_request( '/wcsn/activate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-5', 'instance' => 'gone' ) ) ); + + $response = rest_do_request( + $this->get_rest_request( '/wcsn/deactivate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-5', 'instance' => 'gone' ) ) + ); + $data = $response->get_data(); + + $this->assertSame( 'key_deactivated', $data['code'] ); + $this->assertSame( 2, $data['activations_left'] ); + } + + /** + * Deactivating without an instance is rejected. + */ + public function testDeactivateRequiresInstance(): void { + $product = $this->make_activatable( 'ACT-6', 2 ); + $response = rest_do_request( + $this->get_rest_request( '/wcsn/deactivate', array( 'product_id' => (string) $product->get_id(), 'serial_key' => 'ACT-6' ) ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'missing_instance', $response->get_data()['code'] ); + } +} diff --git a/tests/phpunit/RestApiValidateTest.php b/tests/phpunit/RestApiValidateTest.php new file mode 100644 index 00000000..cd703bb4 --- /dev/null +++ b/tests/phpunit/RestApiValidateTest.php @@ -0,0 +1,148 @@ +create_product(); + $order = $this->create_order( $product, 1, array( 'status' => 'completed' ) ); + $key = Key::insert( + array( + 'product_id' => $product->get_id(), + 'serial_key' => $serial, + 'status' => $status, + 'order_id' => $order->get_id(), + ) + ); + $this->assertNotWPError( $key ); + + return array( $product, $order, $key ); + } + + /** + * An unknown product id is rejected. + */ + public function testRejectsInvalidProduct(): void { + $request = $this->get_rest_request( '/wcsn/validate', array( 'product_id' => 99999999, 'serial_key' => 'X' ) ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'invalid_product_id', $response->get_data()['code'] ); + } + + /** + * A serial key that does not exist is rejected. + */ + public function testRejectsUnknownKey(): void { + $product = $this->create_product(); + $request = $this->get_rest_request( '/wcsn/validate', array( 'product_id' => $product->get_id(), 'serial_key' => 'DOES-NOT-EXIST' ) ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'invalid_key', $response->get_data()['code'] ); + } + + /** + * A key whose order is not completed is rejected. + */ + public function testRejectsKeyWithoutCompletedOrder(): void { + $product = $this->create_product(); + $order = $this->create_order( $product, 1, array( 'status' => 'processing' ) ); + $key = Key::insert( + array( + 'product_id' => $product->get_id(), + 'serial_key' => 'PROCESSING-1', + 'status' => 'sold', + 'order_id' => $order->get_id(), + ) + ); + $this->assertNotWPError( $key ); + + $request = $this->get_rest_request( '/wcsn/validate', array( 'product_id' => $product->get_id(), 'serial_key' => 'PROCESSING-1' ) ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'invalid_order', $response->get_data()['code'] ); + } + + /** + * A mismatched email is rejected. + */ + public function testEmailMismatchRejected(): void { + // The routes declare an invalid 'email' schema type; pin that known notice. + $this->setExpectedIncorrectUsage( 'rest_validate_value_from_schema' ); + $this->setExpectedIncorrectUsage( 'rest_sanitize_value_from_schema' ); + + list( $product, , ) = $this->make_bound_key( 'EMAIL-1' ); + + $request = $this->get_rest_request( + '/wcsn/validate', + array( + 'product_id' => $product->get_id(), + 'serial_key' => 'EMAIL-1', + 'email' => 'wrong@example.com', + ) + ); + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'invalid_email', $response->get_data()['code'] ); + } + + /** + * Expired and cancelled keys are rejected with their own codes. + */ + public function testExpiredAndCancelledStatusRejected(): void { + list( $expired_product, , ) = $this->make_bound_key( 'EXP-1', 'expired' ); + $expired = rest_do_request( $this->get_rest_request( '/wcsn/validate', array( 'product_id' => $expired_product->get_id(), 'serial_key' => 'EXP-1' ) ) ); + $this->assertSame( 'expired_key', $expired->get_data()['code'] ); + + list( $cancelled_product, , ) = $this->make_bound_key( 'CAN-1', 'cancelled' ); + $cancelled = rest_do_request( $this->get_rest_request( '/wcsn/validate', array( 'product_id' => $cancelled_product->get_id(), 'serial_key' => 'CAN-1' ) ) ); + $this->assertSame( 'key_cancelled', $cancelled->get_data()['code'] ); + } + + /** + * A valid sold key returns the documented response contract. + */ + public function testValidSoldKeyReturnsContract(): void { + list( $product, , ) = $this->make_bound_key( 'VALID-1' ); + + $response = rest_do_request( $this->get_rest_request( '/wcsn/validate', array( 'product_id' => $product->get_id(), 'serial_key' => 'VALID-1' ) ) ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'key_valid', $data['code'] ); + $this->assertSame( 'active', $data['status'] ); + $this->assertArrayHasKey( 'activation_limit', $data ); + $this->assertArrayHasKey( 'activation_count', $data ); + $this->assertArrayHasKey( 'activations_left', $data ); + $this->assertArrayHasKey( 'expire_date', $data ); + } +} diff --git a/tests/phpunit/StockTest.php b/tests/phpunit/StockTest.php new file mode 100644 index 00000000..184a61e3 --- /dev/null +++ b/tests/phpunit/StockTest.php @@ -0,0 +1,99 @@ +create_product(); + $this->make_available_key( $product->get_id(), 'S-1' ); + $this->make_available_key( $product->get_id(), 'S-2' ); + $this->make_available_key( $product->get_id(), 'S-3' ); + + // A sold key (not tied to an order) must not count toward available stock. + $sold = Key::insert( + array( + 'product_id' => $product->get_id(), + 'serial_key' => 'S-SOLD', + 'status' => 'sold', + ) + ); + $this->assertNotWPError( $sold ); + + $this->assertSame( 3, (int) wcsn_get_product_stock( $product->get_id() ) ); + } + + /** + * Checkout is blocked when there are not enough available keys. + */ + public function testCheckoutBlockedWhenNotEnough(): void { + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'C-1' ); + + if ( is_null( WC()->cart ) ) { + wc_load_cart(); + } + WC()->cart->empty_cart(); + WC()->cart->add_to_cart( $product->get_id(), 2 ); + wc_clear_notices(); + + Orders::validate_checkout(); + + $this->assertNotEmpty( wc_get_notices( 'error' ) ); + + WC()->cart->empty_cart(); + wc_clear_notices(); + } + + /** + * Checkout passes when there is enough stock. + */ + public function testCheckoutPassesWithEnough(): void { + $product = $this->create_product(); + $this->make_available_key( $product->get_id(), 'C-1' ); + $this->make_available_key( $product->get_id(), 'C-2' ); + + if ( is_null( WC()->cart ) ) { + wc_load_cart(); + } + WC()->cart->empty_cart(); + WC()->cart->add_to_cart( $product->get_id(), 2 ); + wc_clear_notices(); + + Orders::validate_checkout(); + + $this->assertEmpty( wc_get_notices( 'error' ) ); + + WC()->cart->empty_cart(); + wc_clear_notices(); + } + + /** + * Low-stock selection returns only products at or below the threshold. + */ + public function testLowStockThresholdSelection(): void { + $low = $this->create_product(); + $this->make_available_key( $low->get_id(), 'L-1' ); + $this->make_available_key( $low->get_id(), 'L-2' ); + + $high = $this->create_product(); + for ( $i = 1; $i <= 10; $i++ ) { + $this->make_available_key( $high->get_id(), 'H-' . $i ); + } + + $counts = wcsn_get_stocks_count( 5 ); + + $this->assertArrayHasKey( $low->get_id(), $counts ); + $this->assertArrayNotHasKey( $high->get_id(), $counts ); + } +} diff --git a/tests/phpunit/TestCase.php b/tests/phpunit/TestCase.php new file mode 100644 index 00000000..a154bb2c --- /dev/null +++ b/tests/phpunit/TestCase.php @@ -0,0 +1,190 @@ +reset_state(); + } + + /** + * Tear down test fixtures. + * + * @return void + */ + public function tearDown(): void { + $this->reset_state(); + parent::tearDown(); + } + + /** + * Truncate custom tables and clear the stock transient. + * + * @return void + */ + protected function reset_state(): void { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}serial_numbers" ); + $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}serial_numbers_activations" ); + delete_transient( 'wcsn_products_stock_count' ); + } + + /** + * Build a REST API request. + * + * @param string $path REST API path. + * @param array $params Request parameters. + * @param string $method HTTP method. + * @return WP_REST_Request + */ + protected function get_rest_request( string $path, array $params = array(), string $method = 'GET' ): WP_REST_Request { + $request = new WP_REST_Request( $method, $path ); + + if ( $params && 'GET' === $method ) { + $request->set_query_params( $params ); + } elseif ( $params && in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) { + $request->set_body_params( $params ); + } + + return $request; + } + + /** + * Set a plugin option. + * + * @param string $name Option name. + * @param mixed $value Option value. + * @return void + */ + protected function set_option( string $name, $value ): void { + update_option( $name, $value ); + } + + /** + * Create an available (unsold) serial key for a product. + * + * @param int $product_id Product ID. + * @param string $serial Plaintext serial key. + * @param array $args Extra key args. + * @return Key + */ + protected function make_available_key( int $product_id, string $serial, array $args = array() ): Key { + $key = Key::insert( + array_merge( + array( + 'product_id' => $product_id, + 'serial_key' => $serial, + 'status' => 'available', + ), + $args + ) + ); + + $this->assertNotWPError( $key, 'make_available_key() failed: ' . ( is_wp_error( $key ) ? $key->get_error_message() : '' ) ); + + return $key; + } + + /** + * Create a simple product enabled for serial numbers. + * + * @param array $args Extra product args. + * @return \WC_Product_Simple + */ + protected function create_product( array $args = array() ): \WC_Product_Simple { + $product = new \WC_Product_Simple(); + $product->set_name( $args['name'] ?? 'Test Software' ); + $product->set_regular_price( $args['price'] ?? '10' ); + $product->set_status( 'publish' ); + if ( isset( $args['manage_stock'] ) ) { + $product->set_manage_stock( $args['manage_stock'] ); + } + if ( isset( $args['stock_quantity'] ) ) { + $product->set_stock_quantity( $args['stock_quantity'] ); + } + $product->save(); + + $enabled = $args['serial_enabled'] ?? true; + if ( $enabled ) { + update_post_meta( $product->get_id(), '_is_serial_number', 'yes' ); + update_post_meta( $product->get_id(), '_serial_key_source', 'custom_source' ); + } + + return $product; + } + + /** + * Create an order with a single product line item. + * + * @param \WC_Product $product Product to add. + * @param int $quantity Quantity. + * @param array $args Extra args (email, status, customer_id). + * @return \WC_Order + */ + protected function create_order( \WC_Product $product, int $quantity = 1, array $args = array() ): \WC_Order { + $order = new \WC_Order(); + $order->add_product( $product, $quantity ); + $order->set_billing_email( $args['email'] ?? 'buyer@example.com' ); + if ( isset( $args['customer_id'] ) ) { + $order->set_customer_id( $args['customer_id'] ); + } + $order->calculate_totals(); + $order->save(); + + if ( ! empty( $args['status'] ) ) { + $order->set_status( $args['status'] ); + $order->save(); + } + + return $order; + } + + /** + * Create a customer. + * + * @param string $email Email. + * @return int Customer ID. + */ + protected function create_customer( string $email = 'buyer@example.com' ): int { + return $this->factory()->user->create( + array( + 'role' => 'customer', + 'user_email' => $email, + ) + ); + } + + /** + * Read the raw stored serial_key ciphertext directly from the DB. + * + * @param int $key_id Key ID. + * @return string + */ + protected function get_raw_serial_key( int $key_id ): string { + global $wpdb; + return (string) $wpdb->get_var( + $wpdb->prepare( "SELECT serial_key FROM {$wpdb->prefix}serial_numbers WHERE id = %d", $key_id ) + ); + } +} \ No newline at end of file