Files
2meet-data-optimizer/tests/integration/PointsAtomicIntegrationTest.php
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:06:36 +08:00

262 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Points_Manager against real MariaDB.
*
* Verifies transaction discipline (BEGIN/COMMIT/ROLLBACK), atomic balance
* serialisation, ledger integrity, and overdraft protection using the real
* InnoDB transaction engine.
*
* Tables created with wp_itest_ prefix to avoid polluting production data.
*/
class PointsAtomicIntegrationTest extends TestCase {
private const MEM_TABLE = 'wp_itest_wpdo_user_membership';
private const LEDGER_TABLE = 'wp_itest_wpdo_user_points_ledger';
private const ERRORS_TABLE = 'wp_itest_wpdo_errors';
// ── Fixture lifecycle ────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Points_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-points-manager.php';
}
if ( ! class_exists( 'WPDO_DB' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-db.php';
}
if ( ! class_exists( 'WPDO_Logger' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-logger.php';
}
$wpdb->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 );
}
}