original_wpdb = $wpdb; // Install a controllable mock that also has insert_id. $wpdb = $this->make_wpdb_mock(); } protected function tearDown(): void { global $wpdb; $wpdb = $this->original_wpdb; } // ── credit() input validation ──────────────────────────────────────────── public function test_credit_rejects_zero_delta(): void { $result = WPDO_Points_Manager::credit( 1, 0 ); $this->assertFalse( $result['ok'] ); $this->assertSame( 'credit delta must be positive', $result['error'] ); } public function test_credit_rejects_negative_delta(): void { $result = WPDO_Points_Manager::credit( 1, -50 ); $this->assertFalse( $result['ok'] ); $this->assertSame( 'credit delta must be positive', $result['error'] ); } // ── debit() input validation ───────────────────────────────────────────── public function test_debit_rejects_zero_delta(): void { $result = WPDO_Points_Manager::debit( 1, 0 ); $this->assertFalse( $result['ok'] ); $this->assertSame( 'debit delta must be positive', $result['error'] ); } public function test_debit_rejects_negative_delta(): void { $result = WPDO_Points_Manager::debit( 1, -10 ); $this->assertFalse( $result['ok'] ); $this->assertSame( 'debit delta must be positive', $result['error'] ); } // ── debit() insufficient balance ───────────────────────────────────────── public function test_debit_fails_when_balance_zero_and_no_overdraft(): void { // $wpdb->get_var returns null → balance = 0; debit 50 → new_balance = -50 → reject. $result = WPDO_Points_Manager::debit( 42, 50, 'purchase' ); $this->assertFalse( $result['ok'] ); $this->assertSame( 'insufficient_balance', $result['error'] ); } public function test_debit_rollback_called_on_insufficient_balance(): void { global $wpdb; WPDO_Points_Manager::debit( 42, 50 ); $sql_log = $wpdb->queries; // Expect BEGIN and ROLLBACK but NOT COMMIT. $this->assertContains( 'START TRANSACTION', $sql_log ); $this->assertContains( 'ROLLBACK', $sql_log ); $this->assertNotContains( 'COMMIT', $sql_log ); } // ── debit() allow_overdraft ─────────────────────────────────────────────── public function test_debit_with_allow_overdraft_succeeds_below_zero(): void { $result = WPDO_Points_Manager::debit( 1, 100, 'force', 0, '', true ); $this->assertTrue( $result['ok'] ); $this->assertSame( -100, $result['balance'] ); } // ── credit() happy path ─────────────────────────────────────────────────── public function test_credit_returns_ok_and_new_balance(): void { $result = WPDO_Points_Manager::credit( 7, 200, 'signup_bonus' ); $this->assertTrue( $result['ok'] ); $this->assertSame( 200, $result['balance'] ); $this->assertArrayHasKey( 'ledger_id', $result ); } public function test_credit_records_begin_and_commit(): void { global $wpdb; WPDO_Points_Manager::credit( 7, 100, 'test' ); $sql_log = $wpdb->queries; $this->assertContains( 'START TRANSACTION', $sql_log ); $this->assertContains( 'COMMIT', $sql_log ); $this->assertNotContains( 'ROLLBACK', $sql_log ); } public function test_credit_truncates_long_reason(): void { // Reasons over 60 chars must be silently truncated (not cause DB error). $long_reason = str_repeat( 'x', 100 ); $result = WPDO_Points_Manager::credit( 5, 10, $long_reason ); $this->assertTrue( $result['ok'] ); } // ── get_balance() ───────────────────────────────────────────────────────── public function test_get_balance_returns_zero_for_unknown_user(): void { // Mock $wpdb->get_var returns null → (int) null = 0. $balance = WPDO_Points_Manager::get_balance( 9999 ); $this->assertSame( 0, $balance ); } // ── get_ledger() ───────────────────────────────────────────────────────── public function test_get_ledger_returns_empty_array_when_no_rows(): void { $ledger = WPDO_Points_Manager::get_ledger( 9999 ); $this->assertSame( array(), $ledger ); } public function test_get_ledger_clamps_limit_between_1_and_500(): void { // Just confirm no exception on extreme inputs. WPDO_Points_Manager::get_ledger( 1, -5 ); WPDO_Points_Manager::get_ledger( 1, 9999 ); $this->assertTrue( true ); } // ── helper ─────────────────────────────────────────────────────────────── /** * Build a $wpdb mock that: * - Records every SQL statement to ->queries[] * - Returns null for SELECT…FOR UPDATE (simulating empty DB / no row) * - After a successful UPSERT, returns the inserted delta for re-read SELECTs * - Returns empty array for get_results * - Returns 1 for query/insert * - Has insert_id = 99 */ private function make_wpdb_mock(): object { return new class { public string $prefix = 'wp_'; public string $postmeta = 'wp_postmeta'; public string $posts = 'wp_posts'; public string $options = 'wp_options'; public string $usermeta = 'wp_usermeta'; public string $users = 'wp_users'; public array $queries = array(); public int $insert_id = 99; public string $last_error = ''; public ?int $last_upserted_balance = null; public function prepare( string $sql, ...$args ): string { $i = 0; return preg_replace_callback( '/%[sd]/', function () use ( &$i, $args ) { return $args[ $i++ ] ?? '?'; }, $sql ); } public function get_var( string $sql ): ?string { $this->queries[] = $sql; // SELECT … FOR UPDATE simulates an empty membership table (no row). if ( false !== strpos( $sql, 'FOR UPDATE' ) ) { return null; } // Post-UPSERT re-read returns the balance written by the last INSERT. if ( null !== $this->last_upserted_balance ) { return (string) $this->last_upserted_balance; } return null; } public function get_results( string $sql, $output = 'OBJECT' ): array { $this->queries[] = $sql; return array(); } public function query( string $sql ): int { $this->queries[] = $sql; // Capture the delta from INSERT…VALUES(user_id, delta) so subsequent // re-read SELECTs can return a meaningful balance (mirrors real DB). if ( preg_match( '/VALUES\s*\(\s*\d+\s*,\s*(-?\d+)\s*\)/', $sql, $m ) ) { $this->last_upserted_balance = (int) $m[1]; } return 1; } public function insert( string $table, array $data, $format = null ): int { $this->queries[] = "INSERT {$table}"; return 1; } public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int { $this->queries[] = "UPDATE {$table}"; return 1; } public function delete( string $table, array $where, $format = null ): int { $this->queries[] = "DELETE {$table}"; return 1; } public function replace( string $table, array $data, $format = null ): int { $this->queries[] = "REPLACE {$table}"; return 1; } }; } }