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, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: '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); } /** * The balance is keyed on the payer, not the student, so a guardian's * account carries the credits every one of their children earned. */ public function testAvailableBalanceQueriesThePayer(): void { $this->db->shouldReceive('prepare') ->once() ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE) ->andReturn('sql'); $this->db->shouldReceive('get_var')->once()->with('sql')->andReturn('60.00'); self::assertSame(60.0, $this->repo->availableBalance(5)); } public function testFindAvailableByPayerReturnsCreditsOldestFirst(): void { $this->db->shouldReceive('prepare') ->once() ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE) ->andReturn('sql'); $this->db->shouldReceive('get_results')->once()->with('sql')->andReturn([ (object) ['id' => '1', 'student_id' => '42', 'payer_id' => '5', 'amount' => '10.00', 'remaining' => '10.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-07-01 09:00:00', 'updated_at' => null], ]); $credits = $this->repo->findAvailableByPayer(5); self::assertCount(1, $credits); self::assertSame(42, $credits[0]->studentId); self::assertSame(5, $credits[0]->payerId); } /** * Rows written before guardian accounts existed carry payer_id 0; the * installer points them at the student who was always the payer. */ public function testBackfillPayerIdsPointsLegacyRowsAtTheStudent(): void { $this->db->shouldReceive('prepare') ->once() ->with('UPDATE %i SET payer_id = student_id WHERE payer_id = 0', 'wp_us_credits') ->andReturn('sql'); $this->db->shouldReceive('query')->once()->with('sql'); $this->repo->backfillPayerIds(); } public function testInsertDefaultsThePayerToTheStudent(): void { Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); $this->db->shouldReceive('insert') ->once() ->with( 'wp_us_credits', Mockery::on(static fn (array $d): bool => $d['student_id'] === 5 && $d['payer_id'] === 5), Mockery::type('array') ); $this->db->insert_id = 301; self::assertSame(301, $this->repo->insert(new Credit(5, 10.0, 10.0))); } }