db = Mockery::mock(\wpdb::class); $this->db->prefix = 'wp_'; $this->repo = new CreditRepository($this->db); } public function testInsertReturnsId(): void { Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00'); $this->db->shouldReceive('insert') ->once() ->with( 'wp_us_credits', Mockery::on(static function (array $d): bool { return $d['student_id'] === 5 && $d['amount'] === 33.0 && $d['remaining'] === 33.0 && $d['source_lesson_id'] === 77 && $d['status'] === Credit::STATUS_AVAILABLE; }), Mockery::type('array') ); $this->db->insert_id = 300; $credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77'); self::assertSame(300, $this->repo->insert($credit)); } public function testExistsForLessonReturnsTrueWhenRowFound(): void { $this->db->shouldReceive('prepare') ->once() ->with(Mockery::pattern('/source_lesson_id = %d/'), 'wp_us_credits', 77) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('300'); self::assertTrue($this->repo->existsForLesson(77)); } public function testExistsForLessonReturnsFalseWhenNone(): void { $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->once()->andReturn(null); self::assertFalse($this->repo->existsForLesson(77)); } public function testAvailableBalanceSumsRemaining(): void { $this->db->shouldReceive('prepare') ->once() ->with(Mockery::pattern('/SUM\( remaining \)/'), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE) ->andReturn('SELECT ...'); $this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('45.00'); self::assertSame(45.0, $this->repo->availableBalance(5)); } public function testConsumeDrawsDownOldestFirstAndMarksSpentConsumed(): void { // Two available credits ($20 then $30); consuming $35 empties the first and // takes $15 from the second, leaving it $15 and still available. $rows = [ (object) ['id' => '1', 'student_id' => '5', 'amount' => '20.00', 'remaining' => '20.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-01 09:00:00', 'updated_at' => null], (object) ['id' => '2', 'student_id' => '5', 'amount' => '30.00', 'remaining' => '30.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-02 09:00:00', 'updated_at' => null], ]; Functions\expect('current_time')->with('mysql')->andReturn('2026-07-15 12:00:00'); $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); $this->db->shouldReceive('get_results')->once()->with('SELECT ...')->andReturn($rows); // First credit fully spent -> consumed. $this->db->shouldReceive('update') ->once() ->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 0.0 && $d['status'] === Credit::STATUS_CONSUMED), ['id' => 1], Mockery::type('array'), Mockery::type('array')); // Second credit partly spent -> stays available with $15 remaining. $this->db->shouldReceive('update') ->once() ->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 15.0 && $d['status'] === Credit::STATUS_AVAILABLE), ['id' => 2], Mockery::type('array'), Mockery::type('array')); $this->repo->consume(5, 35.0); } public function testConsumeIgnoresNonPositiveAmount(): void { $this->db->shouldNotReceive('get_results'); $this->db->shouldNotReceive('update'); $this->repo->consume(5, 0.0); } }