test(astrolabe): add unit tests for background token refresh

- Fix McpTokenStorageTest: add IDBConnection mock for new constructor parameter
- Add doctrine/dbal dev dependency for IQueryBuilder mock support
- Add tests for getAllUsersWithTokens() database query method
- Create RefreshUserTokensTest with comprehensive coverage:
  - Job interval configuration (15 min)
  - Token refresh threshold logic (50% lifetime)
  - issued_at tracking for accurate lifetime calculation
  - Fallback to default lifetime when issued_at missing
  - Token rotation handling
  - Error handling and logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-01-27 12:23:06 +01:00
parent c46f9eb212
commit 815a09be34
4 changed files with 818 additions and 8 deletions
+1
View File
@@ -39,6 +39,7 @@
"php": "^8.1"
},
"require-dev": {
"doctrine/dbal": "^3.8",
"nextcloud/ocp": "dev-stable30",
"phpunit/phpunit": "^10.0",
"roave/security-advisories": "dev-latest"
+303 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "94a9d7f7619235ef2a310deec2ce14f0",
"content-hash": "e6ea5a770c578a5d7694602bb2618cef",
"packages": [
{
"name": "bamarni/composer-bin-plugin",
@@ -65,6 +65,259 @@
}
],
"packages-dev": [
{
"name": "doctrine/dbal",
"version": "3.10.4",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2",
"doctrine/deprecations": "^0.5.3|^1",
"doctrine/event-manager": "^1|^2",
"php": "^7.4 || ^8.0",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"conflict": {
"doctrine/cache": "< 1.11"
},
"require-dev": {
"doctrine/cache": "^1.11|^2.0",
"doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.29",
"slevomat/coding-standard": "8.24.0",
"squizlabs/php_codesniffer": "4.0.0",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
},
"bin": [
"bin/doctrine-dbal"
],
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\DBAL\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
"keywords": [
"abstraction",
"database",
"db2",
"dbal",
"mariadb",
"mssql",
"mysql",
"oci8",
"oracle",
"pdo",
"pgsql",
"postgresql",
"queryobject",
"sasql",
"sql",
"sqlite",
"sqlserver",
"sqlsrv"
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
"type": "tidelift"
}
],
"time": "2025-11-29T10:46:08+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=13"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^13",
"phpstan/phpstan": "1.4.10 || 2.1.11",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.5"
},
"time": "2025-04-07T20:06:18+00:00"
},
{
"name": "doctrine/event-manager",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312",
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"conflict": {
"doctrine/common": "<2.9"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpdocumentor/guides-cli": "^1.4",
"phpstan/phpstan": "^2.1.32",
"phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
},
{
"name": "Johannes Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com"
}
],
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
"keywords": [
"event",
"event dispatcher",
"event manager",
"event system",
"events"
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
"source": "https://github.com/doctrine/event-manager/tree/2.1.0"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
"type": "tidelift"
}
],
"time": "2026-01-17T22:40:21+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
@@ -775,6 +1028,55 @@
],
"time": "2025-12-06T07:50:42+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
@@ -0,0 +1,435 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Tests\Unit\BackgroundJob;
use OCA\Astrolabe\BackgroundJob\RefreshUserTokens;
use OCA\Astrolabe\Service\IdpTokenRefresher;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Utility\ITimeFactory;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for RefreshUserTokens background job.
*
* Tests proactive OAuth token refresh functionality.
*/
final class RefreshUserTokensTest extends TestCase {
private ITimeFactory&MockObject $timeFactory;
private McpTokenStorage&MockObject $tokenStorage;
private IdpTokenRefresher&MockObject $tokenRefresher;
private LoggerInterface&MockObject $logger;
private RefreshUserTokens $job;
protected function setUp(): void {
parent::setUp();
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->tokenStorage = $this->createMock(McpTokenStorage::class);
$this->tokenRefresher = $this->createMock(IdpTokenRefresher::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->job = new RefreshUserTokens(
$this->timeFactory,
$this->tokenStorage,
$this->tokenRefresher,
$this->logger
);
}
// =========================================================================
// Constructor Tests
// =========================================================================
public function testConstructorSetsInterval(): void {
// Use reflection to access the protected interval property
$reflection = new \ReflectionClass($this->job);
$property = $reflection->getProperty('interval');
$property->setAccessible(true);
$this->assertEquals(900, $property->getValue($this->job));
}
// =========================================================================
// run() Method Tests
// =========================================================================
public function testRunWithNoUsers(): void {
$this->tokenStorage->method('getAllUsersWithTokens')
->willReturn([]);
$this->logger->expects($this->exactly(2))
->method('info')
->willReturnCallback(function (string $message) {
static $callCount = 0;
$callCount++;
if ($callCount === 1) {
$this->assertStringContainsString('Starting', $message);
} else {
$this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message);
}
});
$this->logger->expects($this->once())
->method('debug')
->with($this->stringContains('Found 0 users'));
// Call run() via reflection since it's protected
$this->invokeRun();
}
public function testRunWithMultipleUsersAndMixedResults(): void {
$this->tokenStorage->method('getAllUsersWithTokens')
->willReturn(['alice', 'bob', 'charlie']);
// Alice: token with plenty of time (skipped)
// Bob: token near expiry with refresh token (refreshed)
// Charlie: token near expiry without refresh token (failed)
$this->tokenStorage->method('getUserToken')
->willReturnCallback(function (string $userId) {
$now = time();
return match ($userId) {
'alice' => [
'access_token' => 'alice-token',
'refresh_token' => 'alice-refresh',
'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime)
'issued_at' => $now,
],
'bob' => [
'access_token' => 'bob-token',
'refresh_token' => 'bob-refresh',
'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime)
'issued_at' => $now - 3500,
],
'charlie' => [
'access_token' => 'charlie-token',
// No refresh_token
'expires_at' => $now + 100,
'issued_at' => $now - 3500,
],
default => null,
};
});
// Bob's refresh should succeed
$this->tokenRefresher->method('refreshAccessToken')
->with('bob-refresh')
->willReturn([
'access_token' => 'bob-new-token',
'refresh_token' => 'bob-new-refresh',
'expires_in' => 3600,
]);
$this->tokenStorage->expects($this->once())
->method('storeUserToken')
->with(
'bob',
'bob-new-token',
'bob-new-refresh',
$this->anything(),
$this->anything()
);
$this->logger->expects($this->exactly(2))
->method('info')
->willReturnCallback(function (string $message) {
static $callCount = 0;
$callCount++;
if ($callCount === 2) {
$this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message);
}
});
$this->invokeRun();
}
// =========================================================================
// refreshUserTokenIfNeeded() Tests
// =========================================================================
public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'valid-token',
'refresh_token' => 'refresh-token',
'expires_at' => $now + 3600, // 1 hour remaining
'issued_at' => $now,
]);
$this->tokenRefresher->expects($this->never())
->method('refreshAccessToken');
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('skipped', $result);
}
public function testRefreshTriggeredWhenTokenNearExpiry(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'expiring-token',
'refresh_token' => 'refresh-token',
'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s)
'issued_at' => $now - 3300, // Issued 55 min ago
]);
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->with('refresh-token')
->willReturn([
'access_token' => 'new-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 3600,
]);
$this->tokenStorage->expects($this->once())
->method('storeUserToken');
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('refreshed', $result);
}
public function testRefreshFailsWhenNoRefreshToken(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'expiring-token',
// No refresh_token
'expires_at' => $now + 100,
'issued_at' => $now - 3500,
]);
$this->logger->expects($this->once())
->method('warning')
->with($this->stringContains('no refresh token'));
$this->tokenRefresher->expects($this->never())
->method('refreshAccessToken');
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('failed', $result);
}
public function testRefreshFailsWhenRefresherReturnsNull(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'expiring-token',
'refresh_token' => 'invalid-refresh',
'expires_at' => $now + 100,
'issued_at' => $now - 3500,
]);
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->with('invalid-refresh')
->willReturn(null);
$this->logger->expects($this->once())
->method('warning')
->with($this->stringContains('Refresh returned null'));
// Should NOT delete token - let on-demand refresh handle cleanup
$this->tokenStorage->expects($this->never())
->method('deleteUserToken');
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('failed', $result);
}
public function testRefreshUsesIssuedAtForLifetimeCalculation(): void {
$now = time();
// Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min)
// 10/60 = 16.7% remaining, which is < 50%, so should refresh
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'token',
'refresh_token' => 'refresh',
'expires_at' => $now + 600, // 10 min remaining
'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min
]);
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->willReturn([
'access_token' => 'new-token',
'refresh_token' => 'new-refresh',
'expires_in' => 3600,
]);
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('refreshed', $result);
}
public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void {
$now = time();
// Token without issued_at, uses default 3600s lifetime
// 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'token',
'refresh_token' => 'refresh',
'expires_at' => $now + 300, // 5 min remaining
// No issued_at
]);
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->willReturn([
'access_token' => 'new-token',
'refresh_token' => 'new-refresh',
'expires_in' => 3600,
]);
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('refreshed', $result);
}
public function testRefreshStoresNewTokenWithIssuedAt(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'old-token',
'refresh_token' => 'old-refresh',
'expires_at' => $now + 100,
'issued_at' => $now - 3500,
]);
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->willReturn([
'access_token' => 'new-token',
'refresh_token' => 'new-refresh',
'expires_in' => 3600,
]);
// Verify storeUserToken is called with issued_at parameter
$this->tokenStorage->expects($this->once())
->method('storeUserToken')
->with(
'testuser',
'new-token',
'new-refresh',
$this->greaterThan($now), // expires_at = now + 3600
$this->greaterThanOrEqual($now) // issued_at = now
);
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('refreshed', $result);
}
public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'old-token',
'refresh_token' => 'original-refresh',
'expires_at' => $now + 100,
'issued_at' => $now - 3500,
]);
// IdP returns new access token but no new refresh token (no rotation)
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->willReturn([
'access_token' => 'new-token',
// No refresh_token in response
'expires_in' => 3600,
]);
// Should use the original refresh token
$this->tokenStorage->expects($this->once())
->method('storeUserToken')
->with(
'testuser',
'new-token',
'original-refresh', // Original refresh token preserved
$this->anything(),
$this->anything()
);
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('refreshed', $result);
}
public function testRefreshHandlesException(): void {
$now = time();
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn([
'access_token' => 'token',
'refresh_token' => 'refresh',
'expires_at' => $now + 100,
'issued_at' => $now - 3500,
]);
$this->tokenRefresher->expects($this->once())
->method('refreshAccessToken')
->willThrowException(new \Exception('Network error'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Failed to refresh'));
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('failed', $result);
}
public function testRefreshSkippedWhenNoToken(): void {
$this->tokenStorage->method('getUserToken')
->with('testuser')
->willReturn(null);
$this->tokenRefresher->expects($this->never())
->method('refreshAccessToken');
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
$this->assertEquals('skipped', $result);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Invoke the protected run() method.
*/
private function invokeRun(): void {
$reflection = new \ReflectionClass($this->job);
$method = $reflection->getMethod('run');
$method->setAccessible(true);
$method->invoke($this->job, null);
}
/**
* Invoke the private refreshUserTokenIfNeeded() method.
*/
private function invokeRefreshUserTokenIfNeeded(string $userId): string {
$reflection = new \ReflectionClass($this->job);
$method = $reflection->getMethod('refreshUserTokenIfNeeded');
$method->setAccessible(true);
return $method->invoke($this->job, $userId);
}
}
@@ -5,7 +5,11 @@ declare(strict_types=1);
namespace OCA\Astrolabe\Tests\Unit\Service;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\DB\IResult;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Security\ICrypto;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -19,6 +23,7 @@ use Psr\Log\LoggerInterface;
final class McpTokenStorageTest extends TestCase {
private IConfig&MockObject $config;
private ICrypto&MockObject $crypto;
private IDBConnection&MockObject $db;
private LoggerInterface&MockObject $logger;
private McpTokenStorage $storage;
@@ -27,11 +32,13 @@ final class McpTokenStorageTest extends TestCase {
$this->config = $this->createMock(IConfig::class);
$this->crypto = $this->createMock(ICrypto::class);
$this->db = $this->createMock(IDBConnection::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->storage = new McpTokenStorage(
$this->config,
$this->crypto,
$this->db,
$this->logger
);
}
@@ -46,15 +53,15 @@ final class McpTokenStorageTest extends TestCase {
$refreshToken = 'refresh-token-456';
$expiresAt = time() + 3600;
$expectedTokenData = [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt,
];
$this->crypto->expects($this->once())
->method('encrypt')
->with(json_encode($expectedTokenData))
->with($this->callback(function (string $json) use ($accessToken, $refreshToken, $expiresAt) {
$data = json_decode($json, true);
return $data['access_token'] === $accessToken
&& $data['refresh_token'] === $refreshToken
&& $data['expires_at'] === $expiresAt
&& isset($data['issued_at']); // issued_at should be set (defaults to time())
}))
->willReturn('encrypted-data');
$this->config->expects($this->once())
@@ -524,4 +531,69 @@ final class McpTokenStorageTest extends TestCase {
$this->assertNull($result);
}
// =========================================================================
// getAllUsersWithTokens Tests
// =========================================================================
public function testGetAllUsersWithTokensReturnsUserIds(): void {
$qb = $this->createMock(IQueryBuilder::class);
$expr = $this->createMock(IExpressionBuilder::class);
$result = $this->createMock(IResult::class);
// Chain builder methods
$qb->method('select')->willReturnSelf();
$qb->method('from')->willReturnSelf();
$qb->method('where')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('expr')->willReturn($expr);
$qb->method('createNamedParameter')->willReturnArgument(0);
$qb->method('executeQuery')->willReturn($result);
// Mock expression builder
$expr->method('eq')->willReturn('mocked_condition');
// Mock result set with multiple users
$result->method('fetch')->willReturnOnConsecutiveCalls(
['userid' => 'admin'],
['userid' => 'alice'],
['userid' => 'bob'],
false // End of results
);
$result->expects($this->once())->method('closeCursor');
$this->db->method('getQueryBuilder')->willReturn($qb);
$userIds = $this->storage->getAllUsersWithTokens();
$this->assertEquals(['admin', 'alice', 'bob'], $userIds);
}
public function testGetAllUsersWithTokensReturnsEmptyArrayWhenNoTokens(): void {
$qb = $this->createMock(IQueryBuilder::class);
$expr = $this->createMock(IExpressionBuilder::class);
$result = $this->createMock(IResult::class);
// Chain builder methods
$qb->method('select')->willReturnSelf();
$qb->method('from')->willReturnSelf();
$qb->method('where')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('expr')->willReturn($expr);
$qb->method('createNamedParameter')->willReturnArgument(0);
$qb->method('executeQuery')->willReturn($result);
// Mock expression builder
$expr->method('eq')->willReturn('mocked_condition');
// Mock empty result set
$result->method('fetch')->willReturn(false);
$result->expects($this->once())->method('closeCursor');
$this->db->method('getQueryBuilder')->willReturn($qb);
$userIds = $this->storage->getAllUsersWithTokens();
$this->assertEquals([], $userIds);
}
}