query( 'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint(20) unsigned NOT NULL, `points_balance` bigint(20) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::LEDGER_TABLE . '` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint(20) unsigned NOT NULL, `delta` int(11) NOT NULL, `balance_after` bigint(20) NOT NULL, `reason` varchar(60) NOT NULL DEFAULT \'\', `ref_id` bigint(20) DEFAULT NULL, `ref_type` varchar(30) DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_created` (`user_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::ERRORS_TABLE . '` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `severity` varchar(10) NOT NULL DEFAULT \'error\', `component` varchar(60) NOT NULL DEFAULT \'\', `context` varchar(60) NOT NULL DEFAULT \'\', `message` text NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); } public static function tearDownAfterClass(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::MEM_TABLE . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::LEDGER_TABLE . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' ); } protected function setUp(): void { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::LEDGER_TABLE . '`' ); } // ── credit() happy path ───────────────────────────────────────────────── public function test_credit_creates_membership_row(): void { $result = WPDO_Points_Manager::credit( 1, 100, 'signup_bonus' ); $this->assertTrue( $result['ok'] ); $this->assertSame( 100, $result['balance'] ); $this->assertGreaterThan( 0, $result['ledger_id'] ); } public function test_credit_accumulates_balance(): void { WPDO_Points_Manager::credit( 2, 200, 'first' ); $result = WPDO_Points_Manager::credit( 2, 300, 'second' ); $this->assertTrue( $result['ok'] ); $this->assertSame( 500, $result['balance'] ); } public function test_credit_writes_ledger_row(): void { global $wpdb; WPDO_Points_Manager::credit( 3, 50, 'test_reason' ); $row = $wpdb->get_row( "SELECT * FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 3", ARRAY_A ); $this->assertNotNull( $row ); $this->assertSame( '50', $row['delta'] ); $this->assertSame( '50', $row['balance_after'] ); $this->assertSame( 'test_reason', $row['reason'] ); } public function test_get_balance_reflects_credits(): void { WPDO_Points_Manager::credit( 4, 75, 'top_up' ); $balance = WPDO_Points_Manager::get_balance( 4 ); $this->assertSame( 75, $balance ); } // ── debit() happy path ────────────────────────────────────────────────── public function test_debit_after_credit_reduces_balance(): void { WPDO_Points_Manager::credit( 5, 300, 'load' ); $result = WPDO_Points_Manager::debit( 5, 100, 'purchase' ); $this->assertTrue( $result['ok'] ); $this->assertSame( 200, $result['balance'] ); } public function test_debit_writes_negative_delta_to_ledger(): void { global $wpdb; WPDO_Points_Manager::credit( 6, 200, 'load' ); WPDO_Points_Manager::debit( 6, 50, 'spend' ); $rows = $wpdb->get_results( "SELECT delta, balance_after FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 6 ORDER BY id", ARRAY_A ); $this->assertCount( 2, $rows ); $this->assertSame( '200', $rows[0]['delta'] ); // credit row. $this->assertSame( '-50', $rows[1]['delta'] ); $this->assertSame( '150', $rows[1]['balance_after'] ); } // ── debit() insufficient balance ───────────────────────────────────────── public function test_debit_fails_when_insufficient(): void { WPDO_Points_Manager::credit( 7, 50, 'load' ); $result = WPDO_Points_Manager::debit( 7, 100, 'purchase' ); $this->assertFalse( $result['ok'] ); $this->assertSame( 'insufficient_balance', $result['error'] ); } public function test_debit_failure_does_not_write_ledger(): void { global $wpdb; WPDO_Points_Manager::credit( 8, 30, 'load' ); WPDO_Points_Manager::debit( 8, 100, 'purchase' ); // should fail. $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 8 AND delta < 0" ); $this->assertSame( 0, $count ); } public function test_debit_failure_preserves_balance(): void { WPDO_Points_Manager::credit( 9, 40, 'load' ); WPDO_Points_Manager::debit( 9, 200, 'purchase' ); // fails. $balance = WPDO_Points_Manager::get_balance( 9 ); $this->assertSame( 40, $balance ); } // ── sequential double-spend scenario ──────────────────────────────────── /** * Simulate the classic double-spend race: * Balance = 100. Two requests each try to debit 80. * With FOR UPDATE serialisation: first succeeds → balance = 20, * second then reads balance = 20 and correctly rejects (insufficient). */ public function test_sequential_debit_only_first_succeeds(): void { WPDO_Points_Manager::credit( 10, 100, 'load' ); $first = WPDO_Points_Manager::debit( 10, 80, 'spend_1' ); $second = WPDO_Points_Manager::debit( 10, 80, 'spend_2' ); $this->assertTrue( $first['ok'], 'First debit should succeed' ); $this->assertSame( 20, $first['balance'] ); $this->assertFalse( $second['ok'], 'Second debit should fail (insufficient)' ); $this->assertSame( 'insufficient_balance', $second['error'] ); $this->assertSame( 20, WPDO_Points_Manager::get_balance( 10 ) ); } public function test_sequential_debits_leave_correct_ledger_count(): void { global $wpdb; WPDO_Points_Manager::credit( 11, 200, 'load' ); WPDO_Points_Manager::debit( 11, 150, 'spend_1' ); // succeeds: balance=50. WPDO_Points_Manager::debit( 11, 150, 'spend_2' ); // fails: insufficient. $debit_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 11 AND delta < 0" ); $this->assertSame( 1, $debit_count, 'Only one successful debit should be in ledger' ); } // ── allow_overdraft ────────────────────────────────────────────────────── public function test_overdraft_debit_goes_negative(): void { WPDO_Points_Manager::credit( 12, 50, 'load' ); $result = WPDO_Points_Manager::debit( 12, 200, 'force', 0, '', true ); $this->assertTrue( $result['ok'] ); $this->assertSame( -150, $result['balance'] ); } // ── ref_id / ref_type ──────────────────────────────────────────────────── public function test_credit_with_ref_id_and_type(): void { global $wpdb; WPDO_Points_Manager::credit( 13, 100, 'order_reward', 9999, 'order' ); $row = $wpdb->get_row( "SELECT ref_id, ref_type FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 13", ARRAY_A ); $this->assertSame( '9999', $row['ref_id'] ); $this->assertSame( 'order', $row['ref_type'] ); } // ── get_ledger() ───────────────────────────────────────────────────────── public function test_get_ledger_returns_entries_newest_first(): void { WPDO_Points_Manager::credit( 14, 100, 'a' ); WPDO_Points_Manager::credit( 14, 200, 'b' ); $ledger = WPDO_Points_Manager::get_ledger( 14 ); $this->assertCount( 2, $ledger ); // Newest-first: second credit (delta=200) should be first. $this->assertSame( '200', $ledger[0]['delta'] ); $this->assertSame( '100', $ledger[1]['delta'] ); } public function test_get_ledger_respects_limit(): void { for ( $i = 1; $i <= 5; $i++ ) { WPDO_Points_Manager::credit( 15, 10, "entry_{$i}" ); } $ledger = WPDO_Points_Manager::get_ledger( 15, 3 ); $this->assertCount( 3, $ledger ); } }