*/ public array $params = []; public ?int $insertId = null; public int $affectedRows = 0; public string $error = ''; public bool $executeResult = true; /** @var array>|null */ public ?array $rowsToReturn = null; /** @var array */ public array $types = [ 'i' => 'i', 's' => 's', ]; public function bind_param(string $types, &...$vars): bool { $this->params = $vars; return true; } public function execute(): bool { return $this->executeResult; } public function close(): bool { return true; } /** * @return object{ fetch_assoc(): ?array, fetch_all(int): array> } */ public function get_result(): object { $rows = $this->rowsToReturn ?? []; return new class($rows) { /** @param array> $rows */ public function __construct(private array $rows) { } public function fetch_assoc(): ?array { return $this->rows[0] ?? null; } /** @return array> */ public function fetch_all(int $mode = MYSQLI_ASSOC): array { return $this->rows; } }; } } } if (!class_exists('ApiKeyRepositoryFakeMysqli')) { class ApiKeyRepositoryFakeMysqli { public string $error = ''; public ApiKeyRepositoryFakeStmt $lastStmt; /** @var array> */ public array $insertedRows = []; public int $nextInsertId = 100; /** @var array> */ public array $rows = []; public function __construct() { $this->lastStmt = new ApiKeyRepositoryFakeStmt(); } public function prepare(string $sql): object { $this->lastStmt = new ApiKeyRepositoryFakeStmt(); $this->lastStmt->lastSql = $sql; return $this->lastStmt; } public function query(string $sql): object { // Used by the schema_bootstrap. Return an empty result stub. $this->lastStmt = new ApiKeyRepositoryFakeStmt(); $this->lastStmt->lastSql = $sql; return $this->lastStmt; } } } if (!class_exists('ApiKeyRepositoryFakeDb')) { class ApiKeyRepositoryFakeDb { public ApiKeyRepositoryFakeMysqli $conn; public string $databaseName = 'truckwash_test'; /** @var array> */ public array $rows = []; public int $nextInsertId = 100; public function __construct() { $this->conn = new ApiKeyRepositoryFakeMysqli(); } public function getDatabase(): string { return $this->databaseName; } public function escape_string(string $value): string { return addslashes($value); } public function query(string $sql): object { return $this->conn->query($sql); } public function conn(): ApiKeyRepositoryFakeMysqli { return $this->conn; } } } /** * Wrap the repository's `find*` calls so they read from our in-memory * `rows` table instead of going through real SQL. We override the * static methods via a subclass. */ if (!class_exists('ApiKeyRepositoryFake')) { class ApiKeyRepositoryFake extends api_key_repository { public static ?ApiKeyRepositoryFakeDb $bound = null; public static ?array $findByKeyId = null; public static ?array $findById = null; public static ?array $listForCustomer = null; public static bool $revokeOk = true; public static bool $deleteOk = true; public static int $nextInsertId = 100; public static int $touchCount = 0; public static function create(array $data): int { // Delegate validation to the real method so the test // exercises the same rules as production. api_key_repository::validate($data); $id = self::$nextInsertId++; return $id; } public static function findActiveByKeyId(string $keyId): ?array { return self::$findByKeyId; } public static function findById(int $id): ?array { return self::$findById; } public static function revoke(int $id): bool { return self::$revokeOk; } public static function delete(int $id): bool { return self::$deleteOk; } public static function listForCustomer(int $customerId, bool $includeRevoked = false): array { return self::$listForCustomer ?? []; } public static function touchLastUsed(int $id): void { self::$touchCount++; } } } beforeEach(function (): void { $this->previousDb = $GLOBALS['db'] ?? null; $GLOBALS['db'] = new ApiKeyRepositoryFakeDb(); ApiKeyRepositoryFake::$bound = new ApiKeyRepositoryFakeDb(); ApiKeyRepositoryFake::$findByKeyId = null; ApiKeyRepositoryFake::$findById = null; ApiKeyRepositoryFake::$listForCustomer = null; ApiKeyRepositoryFake::$revokeOk = true; ApiKeyRepositoryFake::$deleteOk = true; ApiKeyRepositoryFake::$nextInsertId = 100; ApiKeyRepositoryFake::$touchCount = 0; }); afterEach(function (): void { if ($this->previousDb !== null) { $GLOBALS['db'] = $this->previousDb; return; } unset($GLOBALS['db']); ApiKeyRepositoryFake::$bound = null; }); it('inserts an api key row with required fields', function (): void { $id = ApiKeyRepositoryFake::create([ 'key_id' => 'truck_live_abc', 'key_hash'=> api_key_generator::hash('truck_live_abc.secretvalue'), 'name' => 'Test Key', 'role' => 'customer', ]); expect($id)->toBe(100); }); it('rejects an api key insert missing required fields', function (): void { expect(fn () => ApiKeyRepositoryFake::create([ 'key_id' => 'truck_live_abc', // key_hash missing 'name' => 'Test Key', 'role' => 'customer', ]))->toThrow(InvalidArgumentException::class); expect(fn () => ApiKeyRepositoryFake::create([ 'key_id' => 'truck_live_abc', 'key_hash'=> 'hash', 'name' => 'Test Key', // role missing ]))->toThrow(InvalidArgumentException::class); }); it('finds an active key by key_id', function (): void { ApiKeyRepositoryFake::$findByKeyId = [ 'id' => 5, 'key_id' => 'truck_live_abc', 'role' => 'admin', 'revoked_at' => null, ]; $row = ApiKeyRepositoryFake::findActiveByKeyId('truck_live_abc'); expect($row) ->toBeArray() ->and($row['id'])->toBe(5) ->and($row['key_id'])->toBe('truck_live_abc'); }); it('returns null when finding an active key for an empty key_id', function (): void { expect(ApiKeyRepositoryFake::findActiveByKeyId(''))->toBeNull(); }); it('finds a key by id regardless of revocation state', function (): void { ApiKeyRepositoryFake::$findById = [ 'id' => 7, 'key_id' => 'truck_live_xyz', 'role' => 'subuser', 'revoked_at' => '2026-08-17 00:00:00', ]; $row = ApiKeyRepositoryFake::findById(7); expect($row) ->toBeArray() ->and($row['revoked_at'])->toBe('2026-08-17 00:00:00'); }); it('revokes a key and returns true on success', function (): void { expect(ApiKeyRepositoryFake::revoke(7))->toBeTrue(); ApiKeyRepositoryFake::$revokeOk = false; expect(ApiKeyRepositoryFake::revoke(7))->toBeFalse(); }); it('lists keys for a customer', function (): void { ApiKeyRepositoryFake::$listForCustomer = [ ['id' => 1, 'key_id' => 'truck_live_a', 'role' => 'customer'], ['id' => 2, 'key_id' => 'truck_live_b', 'role' => 'customer'], ]; $rows = ApiKeyRepositoryFake::listForCustomer(42); expect($rows)->toHaveCount(2); expect($rows[0]['key_id'])->toBe('truck_live_a'); }); it('deletes a key and reports the result', function (): void { expect(ApiKeyRepositoryFake::delete(7))->toBeTrue(); ApiKeyRepositoryFake::$deleteOk = false; expect(ApiKeyRepositoryFake::delete(7))->toBeFalse(); }); it('touches last_used_at for a key', function (): void { expect(ApiKeyRepositoryFake::$touchCount)->toBe(0); ApiKeyRepositoryFake::touchLastUsed(7); expect(ApiKeyRepositoryFake::$touchCount)->toBe(1); ApiKeyRepositoryFake::touchLastUsed(7); expect(ApiKeyRepositoryFake::$touchCount)->toBe(2); }); it('ensureTables is idempotent and safe to call without a real DB', function (): void { // The fake $db swallows queries; this should not throw. api_key_schema_bootstrap::ensureTables(); api_key_schema_bootstrap::ensureTables(); expect(true)->toBeTrue(); }); it('tableExists returns false when there is no DB', function (): void { unset($GLOBALS['db']); expect(api_key_schema_bootstrap::tableExists())->toBeFalse(); });